From bd7f4b611eb23ce8b901ad261308e1b6274d79b6 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:25:28 -0400 Subject: [PATCH 1/5] Add typed Page relationships to Content --- .../.agents/skills/document-editing/SKILL.md | 7 + .../references/typed-relationships.md | 52 + .../actions/_canonical-relation-guard.ts | 78 + .../content/actions/_collection-export.ts | 34 +- .../_content-database-row-migration.ts | 9 + templates/content/actions/_content-spaces.ts | 10 +- .../content/actions/_database-source-utils.ts | 7 + templates/content/actions/_database-utils.ts | 13 + .../content/actions/_delete-content-space.ts | 16 +- templates/content/actions/_property-utils.ts | 181 +- .../actions/_relationship-authority.ts | 300 +++ .../actions/_relationship-compatibility.ts | 347 ++++ .../content/actions/_relationship-core.ts | 809 ++++++++ .../actions/_relationship-lifecycle.ts | 252 +++ .../content/actions/_relationship-read.ts | 419 ++++ ...d-content-database-source-field.db.test.ts | 49 + .../bind-content-database-source-field.ts | 29 + .../canonical-relation-integration.db.test.ts | 498 +++++ .../configure-content-relation-property.ts | 536 +++++ .../actions/configure-document-property.ts | 16 +- .../content/actions/content-spaces.db.test.ts | 6 +- .../actions/delete-content-database.ts | 3 +- .../content/actions/delete-content-space.ts | 4 +- .../actions/delete-document-property.ts | 6 + .../content/actions/delete-document.test.ts | 9 + templates/content/actions/delete-document.ts | 36 +- .../actions/duplicate-database-item.ts | 5 + .../actions/duplicate-database-items.ts | 5 + .../actions/duplicate-document-property.ts | 6 + .../list-content-relation-candidates.ts | 332 ++++ .../list-content-relationship-history.ts | 646 ++++++ .../list-content-relationship-types.ts | 127 ++ .../actions/list-content-relationships.ts | 14 + .../actions/mcp-action-contract.spec.ts | 18 + .../migrate-content-database-rows.db.test.ts | 35 + .../actions/migrate-content-database-rows.ts | 16 +- .../actions/mutate-content-relationships.ts | 814 ++++++++ .../actions/permanently-delete-document.ts | 3 +- .../prepare-content-relationship-removal.ts | 306 +++ .../relationship-concurrency.postgres.test.ts | 517 +++++ .../actions/relationship-services.db.test.ts | 1165 +++++++++++ .../actions/relationship-undo.db.test.ts | 749 +++++++ .../remove-content-relation-property.ts | 530 +++++ .../actions/restore-content-database.ts | 3 +- templates/content/actions/restore-document.ts | 3 +- .../content/actions/set-document-property.ts | 13 +- .../stage-builder-source-bulk-update.ts | 7 + .../submit-content-database-form.db.test.ts | 23 + .../actions/submit-content-database-form.ts | 15 + .../undo-content-relationship-revision.ts | 1238 ++++++++++++ .../content/actions/update-database-items.ts | 5 + templates/content/actions/view-screen.test.ts | 248 ++- templates/content/actions/view-screen.ts | 151 +- .../editor/ContentRelationships.test.ts | 193 ++ .../editor/ContentRelationships.tsx | 1754 +++++++++++++++++ .../editor/DocumentEditor.layout.test.ts | 3 + .../app/components/editor/DocumentEditor.tsx | 5 +- .../components/editor/DocumentInfoPanel.tsx | 7 + .../editor/DocumentProperties.test.ts | 4 +- .../components/editor/DocumentProperties.tsx | 729 ++++--- .../RelationPropertyConfigurationDialog.tsx | 502 +++++ .../editor/database/DatabaseView.tsx | 38 +- .../hooks/use-content-relationships.test.ts | 167 ++ .../app/hooks/use-content-relationships.ts | 259 +++ .../app/hooks/use-relationship-app-state.ts | 83 + templates/content/app/i18n-data.ts | 764 +++++++ templates/content/app/i18n/zh-TW.ts | 90 + .../2026-09-08-typed-page-relationships.md | 5 + .../capabilities/content.relationship.edge.md | 10 +- .../content/docs/product/encyclopedia.md | 10 +- templates/content/server/db/schema.ts | 362 ++++ templates/content/server/plugins/db.ts | 239 +++ templates/content/shared/properties.test.ts | 13 + templates/content/shared/properties.ts | 14 + templates/content/shared/relationships.ts | 539 +++++ 75 files changed, 16179 insertions(+), 331 deletions(-) create mode 100644 templates/content/.agents/skills/document-editing/references/typed-relationships.md create mode 100644 templates/content/actions/_canonical-relation-guard.ts create mode 100644 templates/content/actions/_relationship-authority.ts create mode 100644 templates/content/actions/_relationship-compatibility.ts create mode 100644 templates/content/actions/_relationship-core.ts create mode 100644 templates/content/actions/_relationship-lifecycle.ts create mode 100644 templates/content/actions/_relationship-read.ts create mode 100644 templates/content/actions/canonical-relation-integration.db.test.ts create mode 100644 templates/content/actions/configure-content-relation-property.ts create mode 100644 templates/content/actions/list-content-relation-candidates.ts create mode 100644 templates/content/actions/list-content-relationship-history.ts create mode 100644 templates/content/actions/list-content-relationship-types.ts create mode 100644 templates/content/actions/list-content-relationships.ts create mode 100644 templates/content/actions/mutate-content-relationships.ts create mode 100644 templates/content/actions/prepare-content-relationship-removal.ts create mode 100644 templates/content/actions/relationship-concurrency.postgres.test.ts create mode 100644 templates/content/actions/relationship-services.db.test.ts create mode 100644 templates/content/actions/relationship-undo.db.test.ts create mode 100644 templates/content/actions/remove-content-relation-property.ts create mode 100644 templates/content/actions/undo-content-relationship-revision.ts create mode 100644 templates/content/app/components/editor/ContentRelationships.test.ts create mode 100644 templates/content/app/components/editor/ContentRelationships.tsx create mode 100644 templates/content/app/components/editor/RelationPropertyConfigurationDialog.tsx create mode 100644 templates/content/app/hooks/use-content-relationships.test.ts create mode 100644 templates/content/app/hooks/use-content-relationships.ts create mode 100644 templates/content/app/hooks/use-relationship-app-state.ts create mode 100644 templates/content/changelog/2026-09-08-typed-page-relationships.md create mode 100644 templates/content/shared/relationships.ts diff --git a/templates/content/.agents/skills/document-editing/SKILL.md b/templates/content/.agents/skills/document-editing/SKILL.md index 0e6d3d61b3b..dfb8a84b9c4 100644 --- a/templates/content/.agents/skills/document-editing/SKILL.md +++ b/templates/content/.agents/skills/document-editing/SKILL.md @@ -101,6 +101,13 @@ pnpm action restore-document --id abc123 pnpm action permanently-delete-document --id abc123 ``` +## Relationships between Pages + +For assignments to People Pages and other typed Page connections, follow +[Typed Relationships](references/typed-relationships.md). Relation columns and +Connections share canonical edges; use the relationship Actions for changes, +including bulk work and recovery. + ## Comments Comments are Notion/Google-Docs-style **inline comments**. Selecting text and commenting leaves the passage **highlighted inline** via a ProseMirror decoration overlay — nothing is written into the markdown body, so the document round-trips unchanged. Each thread stores the quoted text plus surrounding context (`anchorPrefix`/`anchorSuffix`) and an approximate `anchorStartOffset`, so the highlight follows the text as the document is edited, disambiguates repeated text, and degrades gracefully (the thread stays in the sidebar) when its text is deleted. diff --git a/templates/content/.agents/skills/document-editing/references/typed-relationships.md b/templates/content/.agents/skills/document-editing/references/typed-relationships.md new file mode 100644 index 00000000000..0127354150b --- /dev/null +++ b/templates/content/.agents/skills/document-editing/references/typed-relationships.md @@ -0,0 +1,52 @@ +# Typed Relationships + +Use this workflow when a database row needs to reference another Page, such as +assigning a campaign deliverable to a People Page containing role and capacity. +A People Page is a record, not an authenticated user. An assignment does not +send notifications, grant access, invite a user, or reserve capacity. + +## Discover, inspect, change, verify + +Discover the exact Database and Page IDs through database Actions. Read the +schema and team context before deciding assignments; do not derive IDs from +names. `list-content-relationship-types` describes available type/projection +identities. `list-content-relation-candidates` narrows eligible Pages for the +selected projection. A Relation Property is one database's projection of a +canonical type; its ID differs from the type and edge IDs. + +Use `list-content-relationships` to inspect the current viewer-accessible +connections and server-issued observation tokens. Use +`mutate-content-relationships` for additions, observed removals or explicit +single-value replacement. Use the route returned for the actual editing +context. Incoming visibility alone never permits removing a connection. + +A request is bounded and atomic. If a selected row is invalid or unauthorized, +report the failure; do not silently skip it or split one atomic instruction +into partial commits. Retry an uncertain write with the same operation ID and +same arguments. A new intended change needs a new operation ID. Verify through +an authorized relationship read before declaring the assignment complete. + +## Configuration and recovery + +`configure-content-relation-property` creates a local directional type or +exposes an existing type through a forward/inverse column. The first slice +supports a Database candidate constraint, forward one/many and inverse many. +Query-based selection, symmetric types, governed catalogs and provider-owned +relationship writes are unsupported; do not approximate them with stored +arrays or another relation column. + +Removing a Property normally preserves its relationships. +`prepare-content-relationship-removal` freezes an exact authorized selection; +`remove-content-relation-property` can remove that selection with the projection +as one recoverable change. New concurrent additions are not part of an older +selection. Do not describe the viewer-accessible count as a global count. + +`list-content-relationship-history` returns attributable committed changes. +`undo-content-relationship-revision` compensates a change under current +permissions and constraints. A stale recovery failure leaves current work +unchanged; do not replace a whole Page snapshot to undo an assignment. + +UI and MCP use these same Actions. Generic property setters and bulk row +setters cannot overwrite canonical relationship values. Never store endpoint +arrays through SQL to bypass this boundary. The exact supported parameters, +capabilities and failure codes live in each Action schema. diff --git a/templates/content/actions/_canonical-relation-guard.ts b/templates/content/actions/_canonical-relation-guard.ts new file mode 100644 index 00000000000..4be640b3fce --- /dev/null +++ b/templates/content/actions/_canonical-relation-guard.ts @@ -0,0 +1,78 @@ +import { ActionContractError } from "@agent-native/core/action"; +import { eq, inArray, or } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { parsePropertyOptions } from "../shared/properties.js"; + +type PropertyDefinitionWithOptions = { + optionsJson?: string | null; +}; + +export function isCanonicalRelationProjection( + definition: PropertyDefinitionWithOptions, +) { + return Boolean( + parsePropertyOptions(definition.optionsJson).relation?.relationshipTypeId, + ); +} + +export function assertNotCanonicalRelationProjection( + definition: PropertyDefinitionWithOptions, + message = "Use mutate-content-relationships for canonical relationship values.", +) { + if (!isCanonicalRelationProjection(definition)) return; + throw new ActionContractError(message, { + errorCode: "USE_RELATIONSHIP_MUTATION", + }); +} + +export async function assertNotCanonicalRelationDefinition( + db: ReturnType, + definition: PropertyDefinitionWithOptions & { id: string }, +) { + assertNotCanonicalRelationProjection(definition); + const [projection] = await db + .select({ id: schema.contentRelationshipProjections.id }) + .from(schema.contentRelationshipProjections) + .where(eq(schema.contentRelationshipProjections.propertyId, definition.id)); + if (projection) { + throw new ActionContractError( + "The Relation Property metadata is inconsistent; use the relationship configuration Actions.", + { + errorCode: "UNAVAILABLE", + statusCode: 503, + }, + ); + } +} + +export async function assertRowsHaveNoCanonicalRelationships( + db: ReturnType, + pageIds: string[], +) { + if (!pageIds.length) return; + const lineages = await db + .select({ id: schema.contentRelationshipLineages.id }) + .from(schema.contentRelationshipLineages) + .where( + or( + inArray(schema.contentRelationshipLineages.sourcePageId, pageIds), + inArray(schema.contentRelationshipLineages.targetPageId, pageIds), + ), + ); + if (!lineages.length) return; + const { activeActivationIdsForLineages } = + await import("./_relationship-core.js"); + const active = await activeActivationIdsForLineages( + db, + lineages.map((lineage) => lineage.id), + ); + if ([...active.values()].some((ids) => ids.length > 0)) { + throw new ActionContractError( + "These Pages cannot be duplicated with the current access and configuration.", + { + errorCode: "UNSUPPORTED_CONFIGURATION", + }, + ); + } +} diff --git a/templates/content/actions/_collection-export.ts b/templates/content/actions/_collection-export.ts index e8ca76e17b3..fcf84c1ba72 100644 --- a/templates/content/actions/_collection-export.ts +++ b/templates/content/actions/_collection-export.ts @@ -36,6 +36,7 @@ import { computedPropertyValue, listPropertiesForDatabase, parseDatabaseViewConfig, + parseLegacyRelationValue, } from "./_property-utils.js"; export interface CollectionExportRequest { @@ -244,6 +245,7 @@ function propertyKey(documentId: string, propertyId: string) { async function loadStoredValues( documentIds: readonly string[], propertyIds: readonly string[], + relationPropertyIds: ReadonlySet = new Set(), ) { const values = new Map(); for (const documentIdChunk of chunks([...documentIds], 180)) { @@ -264,7 +266,9 @@ async function loadStoredValues( for (const row of rows) { values.set( propertyKey(row.documentId, row.propertyId), - parsePropertyValue(row.valueJson), + relationPropertyIds.has(row.propertyId) + ? parseLegacyRelationValue(row.valueJson) + : parsePropertyValue(row.valueJson), ); } } @@ -539,7 +543,15 @@ export async function buildCollectionExportProjection( !isComputedPropertyType(property.definition.type), ) .map((property) => property.definition.id); - const storedValues = await loadStoredValues(documentIds, storedPropertyIds); + const storedValues = await loadStoredValues( + documentIds, + storedPropertyIds, + new Set( + requiredProperties + .filter((property) => property.definition.type === "relation") + .map((property) => property.definition.id), + ), + ); const additionalBlockIds = requiredBlocks .filter((property) => !isPrimaryBlocksField(property.definition.options)) .map((property) => property.definition.id); @@ -558,6 +570,19 @@ export async function buildCollectionExportProjection( const requiredRelations = requiredProperties.filter( (property) => property.definition.type === "relation", ); + if ( + requiredRelations.some( + (property) => property.definition.options.relation?.relationshipTypeId, + ) + ) { + const { readCanonicalRelationPropertyValues } = + await import("./_relationship-compatibility.js"); + const canonical = await readCanonicalRelationPropertyValues({ + databaseId: database.id, + pageIds: documentIds, + }); + for (const [key, value] of canonical) storedValues.set(key, value); + } const linkedIds = new Set(); for (const relation of requiredRelations) { for (const documentId of documentIds) { @@ -636,11 +661,12 @@ export async function buildCollectionExportProjection( const target = propertyById.get(config.targetPropertyId); if ( target && - (isComputedPropertyType(target.definition.type) || + (target.definition.options.relation?.relationshipTypeId || + isComputedPropertyType(target.definition.type) || isBlocksPropertyType(target.definition.type)) ) { fail( - `Rollup "${rollup.definition.name}" targets a computed or Blocks property that this bounded export cannot hydrate safely.`, + `Rollup "${rollup.definition.name}" targets a Relation, computed, or Blocks property that this bounded export cannot hydrate safely.`, { errorCode: "collection_export_rollup_target_unsupported", statusCode: 422, diff --git a/templates/content/actions/_content-database-row-migration.ts b/templates/content/actions/_content-database-row-migration.ts index 3754a28a0fe..8dc314da892 100644 --- a/templates/content/actions/_content-database-row-migration.ts +++ b/templates/content/actions/_content-database-row-migration.ts @@ -11,6 +11,7 @@ import { serializePropertyOptions, } from "../shared/properties.js"; import { chunks } from "./_batch-utils.js"; +import { assertNotCanonicalRelationProjection } from "./_canonical-relation-guard.js"; const propertyType = z.enum(["text", "url", "date", "multi_select"]); const option = z.object({ @@ -271,6 +272,10 @@ export function validatePlan( const definition = oldDefs.get(propertyId); if (!definition || definition.systemRole || definition.type === "blocks") throw new Error("Legacy property is missing or unsafe to finalize."); + assertNotCanonicalRelationProjection( + definition, + "Canonical relationship projections cannot be finalized as legacy migration properties.", + ); if ( snapshot.sourceFields.some( (field: any) => field.propertyId === propertyId, @@ -323,6 +328,10 @@ export function validatePlan( ].includes(definition.type) ) throw new Error("Unsafe protected property target."); + assertNotCanonicalRelationProjection( + definition, + "Canonical relationship projections cannot be copied as protected migration values.", + ); const persistedValue = snapshot.values.find( (v: any) => diff --git a/templates/content/actions/_content-spaces.ts b/templates/content/actions/_content-spaces.ts index a73eb2e9dab..aebe5358f48 100644 --- a/templates/content/actions/_content-spaces.ts +++ b/templates/content/actions/_content-spaces.ts @@ -7,6 +7,7 @@ import { isComputedPropertyType, type DocumentPropertyType, } from "../shared/properties.js"; +import { assertNotCanonicalRelationProjection } from "./_canonical-relation-guard.js"; import { listContentOrganizationMemberships, normalizeContentSpaceEmail, @@ -751,8 +752,13 @@ async function provisionOwnedContentSpace( ); for (const [propertyId, value] of initialPropertyValues) { const definition = definitionById.get(propertyId); - const type = definition?.type as DocumentPropertyType | undefined; - if (!type || isComputedPropertyType(type)) continue; + if (!definition) continue; + const type = definition.type as DocumentPropertyType; + if (isComputedPropertyType(type)) continue; + assertNotCanonicalRelationProjection( + definition, + "Content space provisioning cannot write canonical relationship projections. Use the relationship actions instead.", + ); await tx .insert(schema.documentPropertyValues) .values({ diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 1665d9e908e..8724d09ce36 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -59,6 +59,7 @@ import { chunks, processWithConcurrency, } from "./_batch-utils.js"; +import { assertNotCanonicalRelationProjection } from "./_canonical-relation-guard.js"; import { LOCAL_FOLDER_SOURCE_TYPE, localFolderSourceIdentityFromMetadata, @@ -6053,6 +6054,12 @@ export async function materializeSourceFieldPropertyValues(args: { const definitionById = new Map( definitions.map((definition) => [definition.id, definition]), ); + for (const definition of definitions) { + assertNotCanonicalRelationProjection( + definition, + "Source fields cannot materialize canonical relationship projections. Use the relationship actions instead.", + ); + } const scopedRows: Array<{ documentId: string; sourceValuesJson: string; diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index fc952146be3..e7f7cbdf3b4 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -61,6 +61,7 @@ import { } from "./_files-system-properties.js"; import { listPropertiesForDatabaseDocuments, + readRelationProjectionValues, listPropertiesForDatabase, serializeDatabase, } from "./_property-utils.js"; @@ -806,6 +807,18 @@ export async function getContentDatabasePageResponse( const queryProperties = databaseProperties.filter((property) => boundedProjectionPropertyIds.has(property.definition.id), ); + const relationValues = await readRelationProjectionValues( + databaseId, + candidateDocuments.map((document) => document.id), + queryProperties.map((property) => ({ + id: property.definition.id, + type: property.definition.type, + optionsJson: JSON.stringify(property.definition.options), + })), + candidateValues, + ); + for (const [key, value] of relationValues) + candidateValueByDocumentAndProperty.set(key, value); const additionalBlocksPropertyIds = queryProperties.flatMap((property) => isBlocksPropertyType(property.definition.type) && !isPrimaryBlocksField(property.definition.options) diff --git a/templates/content/actions/_delete-content-space.ts b/templates/content/actions/_delete-content-space.ts index 3b4d0ff9a4a..035a7ccc676 100644 --- a/templates/content/actions/_delete-content-space.ts +++ b/templates/content/actions/_delete-content-space.ts @@ -1,3 +1,4 @@ +import type { ActionRunContext } from "@agent-native/core/action"; import { and, eq } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; @@ -10,7 +11,11 @@ import { type Db = ReturnType; const MAX_DELETE_SCOPE_ATTEMPTS = 3; -async function deleteUserContentSpaceOnce(db: Db, spaceId: string) { +async function deleteUserContentSpaceOnce( + db: Db, + spaceId: string, + context?: ActionRunContext, +) { return db.transaction(async (tx) => { const scopedDb = tx as unknown as Db; const access = await resolveContentSpaceAccess(spaceId, "editor", { @@ -53,6 +58,7 @@ async function deleteUserContentSpaceOnce(db: Db, spaceId: string) { scopedDb, [mapping.documentId, filesDatabase.documentId], access.space.ownerEmail, + context, ); const remainingDocuments = await scopedDb @@ -81,10 +87,14 @@ async function deleteUserContentSpaceOnce(db: Db, spaceId: string) { }); } -export async function deleteUserContentSpace(db: Db, spaceId: string) { +export async function deleteUserContentSpace( + db: Db, + spaceId: string, + context?: ActionRunContext, +) { for (let attempt = 1; attempt <= MAX_DELETE_SCOPE_ATTEMPTS; attempt += 1) { try { - return await deleteUserContentSpaceOnce(db, spaceId); + return await deleteUserContentSpaceOnce(db, spaceId, context); } catch (error) { if ( !(error instanceof PermanentDeleteScopeChangedError) || diff --git a/templates/content/actions/_property-utils.ts b/templates/content/actions/_property-utils.ts index 1a119e8501d..3c55d68cbb6 100644 --- a/templates/content/actions/_property-utils.ts +++ b/templates/content/actions/_property-utils.ts @@ -1,3 +1,5 @@ +import { fail } from "@agent-native/core/action"; +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; import { accessFilter, assertAccess, @@ -50,6 +52,7 @@ import { } from "../shared/properties.js"; import { chunks } from "./_batch-utils.js"; import { readBlocksFieldIdentities } from "./_blocks-field-identity.js"; +import { listContentOrganizationMemberships } from "./_content-space-access.js"; import { nextAppendPosition, propertyDefinitionsPositionScope, @@ -636,6 +639,15 @@ export async function listPropertiesForDatabase( ); const includeContainerDerivedValues = options.includeContainerDerivedValues !== false; + const relationValues = + valueDocument && includeContainerDerivedValues + ? await readRelationProjectionValues( + databaseId, + [valueDocument.id], + definitions, + values, + ) + : new Map(); const rowNumberByDocumentId = valueDocument && includeContainerDerivedValues ? await databaseRowNumbersByDocumentId(databaseId) @@ -699,7 +711,15 @@ export async function listPropertiesForDatabase( documentBody: valueDocument.content, blockFieldContent: blockContentByPropertyId.get(definition.id), }) - : parsePropertyValue(storedValue?.valueJson); + : type === "relation" && + valueDocument && + includeContainerDerivedValues + ? requiredRelationProjectionValue( + relationValues, + valueDocument.id, + definition.id, + ) + : parsePropertyValue(storedValue?.valueJson); return { definition: { id: definition.id, @@ -724,7 +744,9 @@ export async function listPropertiesForDatabase( editable: includeContainerDerivedValues && !definition.systemRole && - !isComputedPropertyType(type), + !isComputedPropertyType(type) && + (!options.relation?.relationshipTypeId || + options.relation.editable === true), ...(valueDocument && isBlocksPropertyType(type) ? { blocksField: blocksFieldIdentityById.get( @@ -848,6 +870,13 @@ export async function listPropertiesForDatabaseDocuments( ]), ); + const relationValues = await readRelationProjectionValues( + databaseId, + documentIds, + definitions, + values, + ); + const rowNumberByDocumentId = definitions.some((definition) => isComputedPropertyType(definition.type as DocumentPropertyType), ) @@ -930,13 +959,21 @@ export async function listPropertiesForDatabaseDocuments( propertyValueKey(document.id, definition.id), ), }) - : parsePropertyValue(storedValue?.valueJson); + : propertyDefinition.type === "relation" + ? requiredRelationProjectionValue( + relationValues, + document.id, + definition.id, + ) + : parsePropertyValue(storedValue?.valueJson); return { definition: propertyDefinition, value, editable: !definition.systemRole && - !isComputedPropertyType(propertyDefinition.type), + !isComputedPropertyType(propertyDefinition.type) && + (!propertyDefinition.options.relation?.relationshipTypeId || + propertyDefinition.options.relation.editable === true), ...(isBlocksPropertyType(propertyDefinition.type) ? { blocksField: blocksFieldIdentityById.get( @@ -980,6 +1017,136 @@ export async function listPropertiesForDatabaseDocuments( return result; } +function requiredRelationProjectionValue( + values: Map, + pageId: string, + propertyId: string, +): DocumentPropertyValue { + const key = propertyValueKey(pageId, propertyId); + if (!values.has(key)) + throw new Error("Relationship projection was not loaded."); + return values.get(key)!; +} + +export async function readRelationProjectionValues( + databaseId: string, + pageIds: string[], + definitions: Array<{ id: string; type: string; optionsJson: string | null }>, + storedValues: Array<{ + documentId: string; + propertyId: string; + valueJson: string | null; + }>, +): Promise> { + const relations = definitions.filter( + (definition) => definition.type === "relation", + ); + const result = new Map(); + if (!relations.length || !pageIds.length) return result; + const legacy = relations.filter( + (definition) => + !parsePropertyOptions(definition.optionsJson).relation + ?.relationshipTypeId, + ); + const byKey = new Map( + storedValues.map((value) => [ + propertyValueKey(value.documentId, value.propertyId), + value, + ]), + ); + const targetIds = new Set(); + for (const pageId of pageIds) { + for (const definition of legacy) { + const key = propertyValueKey(pageId, definition.id); + const stored = byKey.get(key); + const value = parseLegacyRelationValue(stored?.valueJson); + result.set(key, value); + for (const id of relationValueIds(value)) targetIds.add(id); + } + } + const visibleIds = new Set(); + const userEmail = getRequestUserEmail(); + if (!userEmail) + throw new Error("Relationship reads require an authenticated actor."); + const memberships = targetIds.size + ? await listContentOrganizationMemberships(userEmail) + : []; + const relationAccess = or( + accessFilter( + schema.documents, + schema.documentShares, + { userEmail }, + "viewer", + { includePublic: true }, + ), + ...memberships.map(({ orgId }) => + accessFilter( + schema.documents, + schema.documentShares, + { userEmail, orgId }, + "viewer", + { includePublic: true }, + ), + ), + ); + for (const ids of chunks([...targetIds], 200)) { + const rows = await getDb() + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + inArray(schema.documents.id, ids), + isNull(schema.documents.trashedAt), + relationAccess, + ), + ); + for (const row of rows) visibleIds.add(row.id); + } + for (const [key, value] of result) { + result.set( + key, + Array.isArray(value) + ? value.filter((id) => typeof id === "string" && visibleIds.has(id)) + : typeof value === "string" + ? visibleIds.has(value) + ? value + : null + : value, + ); + } + if (legacy.length !== relations.length) { + const { readCanonicalRelationPropertyValues } = + await import("./_relationship-compatibility.js"); + const canonical = await readCanonicalRelationPropertyValues({ + databaseId, + pageIds, + }); + for (const [key, value] of canonical) result.set(key, value); + } + return result; +} + +export function parseLegacyRelationValue( + valueJson: string | null | undefined, +): string | string[] | null { + let value: unknown; + try { + value = JSON.parse(valueJson ?? "null"); + } catch { + fail("Legacy relationship data is unreadable; preserve it for migration.", { + errorCode: "UNSUPPORTED_CONFIGURATION", + statusCode: 422, + }); + } + if (value === null || typeof value === "string") return value; + if (Array.isArray(value) && value.every((id) => typeof id === "string")) + return value; + fail("Legacy relationship value is unsupported; preserve it for migration.", { + errorCode: "UNSUPPORTED_CONFIGURATION", + statusCode: 422, + }); +} + async function evaluatePropertyRollup( property: DocumentProperty, properties: DocumentProperty[], @@ -1043,6 +1210,12 @@ async function propertyValuesForLinkedDocuments( documentIds: string[], property: DocumentProperty, ) { + if (property.definition.options.relation?.relationshipTypeId) { + fail("Rollups targeting a canonical Relation Property are not supported.", { + errorCode: "UNSUPPORTED_CONFIGURATION", + statusCode: 422, + }); + } const db = getDb(); const docs = await db .select() diff --git a/templates/content/actions/_relationship-authority.ts b/templates/content/actions/_relationship-authority.ts new file mode 100644 index 00000000000..69aa707bbce --- /dev/null +++ b/templates/content/actions/_relationship-authority.ts @@ -0,0 +1,300 @@ +import type { ActionRunContext } from "@agent-native/core/action"; +import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import type { RelationshipRouteRef } from "../shared/relationships.js"; +import { + loadRelationshipDatabase, + relationshipError, + requireRelationshipDocumentAccess, + type RelationshipDb, + type RelationshipTypeBundle, +} from "./_relationship-core.js"; + +export interface AuthorizedRelationshipRoute { + route: RelationshipRouteRef; + databaseIds: string[]; + propertyIds: string[]; + source: typeof schema.documents.$inferSelect; + target: typeof schema.documents.$inferSelect; +} + +async function assertEndpointEligibility( + db: RelationshipDb, + args: { + bundle: RelationshipTypeBundle; + source: typeof schema.documents.$inferSelect; + target: typeof schema.documents.$inferSelect; + admissionRequired: boolean; + }, +): Promise { + const { bundle, source, target } = args; + if ( + source.id === target.id || + source.spaceId !== bundle.type.spaceId || + target.spaceId !== bundle.type.spaceId || + source.orgId !== bundle.type.orgId || + target.orgId !== bundle.type.orgId + ) { + relationshipError( + "INVALID_TARGET", + "Relationship endpoints must be different Pages in the relationship type's Content space and tenant.", + ); + } + const permanentlyDeleted = await db + .select({ pageId: schema.contentRelationshipEndpointStates.pageId }) + .from(schema.contentRelationshipEndpointStates) + .where( + and( + eq( + schema.contentRelationshipEndpointStates.spaceId, + bundle.type.spaceId, + ), + isNotNull( + schema.contentRelationshipEndpointStates.permanentlyDeletedAt, + ), + inArray(schema.contentRelationshipEndpointStates.pageId, [ + source.id, + target.id, + ]), + ), + ); + if ( + permanentlyDeleted.some( + (state) => state.pageId === source.id || state.pageId === target.id, + ) + ) { + relationshipError( + "INVALID_TARGET", + "A relationship endpoint is unavailable.", + { statusCode: 409 }, + ); + } + if (!args.admissionRequired) return; + if (source.trashedAt || target.trashedAt) { + relationshipError( + "INVALID_TARGET", + "Trashed Pages cannot be admitted to a relationship.", + { statusCode: 409 }, + ); + } + const [sourceMembership] = await db + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq( + schema.contentDatabaseItems.databaseId, + bundle.version.sourceDatabaseId, + ), + eq(schema.contentDatabaseItems.documentId, source.id), + ), + ); + const [targetMembership] = await db + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq( + schema.contentDatabaseItems.databaseId, + bundle.version.targetDatabaseId, + ), + eq(schema.contentDatabaseItems.documentId, target.id), + ), + ); + if (!sourceMembership || !targetMembership) { + relationshipError( + "INVALID_TARGET", + "A Page is outside the relationship type's current Database selection.", + { statusCode: 409 }, + ); + } +} + +async function assertAdmissionDatabasesAvailable( + db: RelationshipDb, + bundle: RelationshipTypeBundle, +): Promise { + const rows = await db + .select({ + id: schema.contentDatabases.id, + deletedAt: schema.contentDatabases.deletedAt, + }) + .from(schema.contentDatabases) + .where( + inArray(schema.contentDatabases.id, [ + bundle.version.sourceDatabaseId, + bundle.version.targetDatabaseId, + ]), + ); + if ( + rows.length !== + new Set([ + bundle.version.sourceDatabaseId, + bundle.version.targetDatabaseId, + ]).size || + rows.some((row) => row.deletedAt) + ) { + relationshipError( + "CONSTRAINT_UNAVAILABLE", + "A relationship admission database is unavailable.", + { statusCode: 409 }, + ); + } +} + +async function assertProjectionIsLocal( + db: RelationshipDb, + propertyId: string, +): Promise { + const [managed] = await db + .select({ id: schema.contentDatabaseSourceFields.id }) + .from(schema.contentDatabaseSourceFields) + .innerJoin( + schema.contentDatabaseSources, + eq( + schema.contentDatabaseSources.id, + schema.contentDatabaseSourceFields.sourceId, + ), + ) + .where( + and( + eq(schema.contentDatabaseSourceFields.propertyId, propertyId), + eq(schema.contentDatabaseSourceFields.writeOwner, "source"), + ), + ); + if (managed) { + relationshipError( + "SOURCE_AUTHORITY_UNSUPPORTED", + "Source-managed relationship mutation is not supported.", + { statusCode: 409 }, + ); + } +} + +export async function authorizeRelationshipRoute(args: { + db?: RelationshipDb; + bundle: RelationshipTypeBundle; + sourcePageId: string; + targetPageId: string; + route: RelationshipRouteRef; + operation: "add" | "remove" | "replace" | "undo"; + context?: ActionRunContext; +}): Promise { + const db = args.db ?? getDb(); + const sourceAccess = await requireRelationshipDocumentAccess( + args.sourcePageId, + args.route.kind === "inverse-property" ? "viewer" : "editor", + { db, context: args.context }, + ); + const targetAccess = await requireRelationshipDocumentAccess( + args.targetPageId, + args.route.kind === "inverse-property" ? "editor" : "viewer", + { db, context: args.context }, + ); + const source = sourceAccess.resource; + const target = targetAccess.resource; + await assertEndpointEligibility(db, { + bundle: args.bundle, + source, + target, + admissionRequired: args.operation === "add" || args.operation === "replace", + }); + if (args.operation === "add" || args.operation === "replace") { + await assertAdmissionDatabasesAvailable(db, args.bundle); + } + + if (args.route.kind === "connections-forward") { + if (args.route.sourcePageId !== source.id) { + relationshipError( + "ROUTE_NOT_AUTHORIZED", + "The Connections route does not match the relationship source Page.", + { statusCode: 403 }, + ); + } + const sourceDatabase = await loadRelationshipDatabase( + args.bundle.version.sourceDatabaseId, + "viewer", + db, + args.context, + { + allowDeleted: args.operation === "remove" || args.operation === "undo", + }, + ); + return { + route: args.route, + databaseIds: [sourceDatabase.database.id], + propertyIds: [], + source, + target, + }; + } + + const propertyId = args.route.propertyId; + const [projection] = await db + .select() + .from(schema.contentRelationshipProjections) + .where( + and( + eq(schema.contentRelationshipProjections.propertyId, propertyId), + eq( + schema.contentRelationshipProjections.relationshipTypeId, + args.bundle.type.id, + ), + isNull(schema.contentRelationshipProjections.archivedAt), + ), + ); + if (!projection) { + relationshipError( + "ROUTE_NOT_AUTHORIZED", + "The relation Property route is unavailable.", + { statusCode: 403 }, + ); + } + await assertProjectionIsLocal(db, propertyId); + if (args.route.kind === "forward-property") { + if ( + projection.direction !== "forward" || + projection.databaseId !== args.bundle.version.sourceDatabaseId || + args.route.sourcePageId !== source.id + ) { + relationshipError( + "ROUTE_NOT_AUTHORIZED", + "The forward relation Property route does not match this edge.", + { statusCode: 403 }, + ); + } + await loadRelationshipDatabase( + projection.databaseId, + "editor", + db, + args.context, + ); + } else { + if ( + projection.direction !== "inverse" || + projection.databaseId !== args.bundle.version.targetDatabaseId || + projection.editable !== 1 || + args.route.targetPageId !== target.id + ) { + relationshipError( + "ROUTE_NOT_AUTHORIZED", + "The inverse relation Property is not an editable route for this edge.", + { statusCode: 403 }, + ); + } + await loadRelationshipDatabase( + projection.databaseId, + "editor", + db, + args.context, + ); + } + return { + route: args.route, + databaseIds: [projection.databaseId], + propertyIds: [projection.propertyId], + source, + target, + }; +} diff --git a/templates/content/actions/_relationship-compatibility.ts b/templates/content/actions/_relationship-compatibility.ts new file mode 100644 index 00000000000..5d43dcbc6bf --- /dev/null +++ b/templates/content/actions/_relationship-compatibility.ts @@ -0,0 +1,347 @@ +import { and, eq, inArray, isNotNull, isNull, or } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { parsePropertyOptions } from "../shared/properties.js"; +import type { + CanonicalRelationProjection, + RelationshipType, + RelationshipTypeVersion, +} from "../shared/relationships.js"; +import { resolveContentDocumentAccess } from "./_content-document-access.js"; +import { + activeActivationIdsForLineages, + loadRelationshipDatabase, + relationshipError, + relationshipProjectionDto, + relationshipTypeDto, + relationshipTypeVersionDto, + type RelationshipDb, +} from "./_relationship-core.js"; + +export interface ResolvedCanonicalRelationProjection { + projection: CanonicalRelationProjection; + relationshipType: RelationshipType; + relationshipTypeVersion: RelationshipTypeVersion; +} + +export async function resolveCanonicalRelationProjection( + propertyId: string, + db: RelationshipDb = getDb(), +): Promise { + const [row] = await db + .select({ + projection: schema.contentRelationshipProjections, + relationshipType: schema.contentRelationshipTypes, + relationshipTypeVersion: schema.contentRelationshipTypeVersions, + }) + .from(schema.contentRelationshipProjections) + .innerJoin( + schema.contentRelationshipTypes, + eq( + schema.contentRelationshipTypes.id, + schema.contentRelationshipProjections.relationshipTypeId, + ), + ) + .innerJoin( + schema.contentRelationshipTypeVersions, + eq( + schema.contentRelationshipTypeVersions.id, + schema.contentRelationshipTypes.currentVersionId, + ), + ) + .where(eq(schema.contentRelationshipProjections.propertyId, propertyId)); + if (!row) return null; + return { + projection: relationshipProjectionDto(row.projection), + relationshipType: relationshipTypeDto(row.relationshipType), + relationshipTypeVersion: relationshipTypeVersionDto( + row.relationshipTypeVersion, + ), + }; +} + +export async function assertCanonicalRelationPropertyValueWrite(args: { + propertyId: string; +}): Promise { + const db = getDb(); + const [definition] = await db + .select({ optionsJson: schema.documentPropertyDefinitions.optionsJson }) + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.id, args.propertyId)); + const marker = parsePropertyOptions(definition?.optionsJson).relation; + const projection = await resolveCanonicalRelationProjection( + args.propertyId, + db, + ); + if (!marker?.relationshipTypeId && !projection) return; + if ( + !projection || + !marker?.relationshipTypeId || + marker.relationshipTypeId !== projection.relationshipType.id || + marker.direction !== projection.projection.direction || + marker.databaseId !== + (projection.projection.direction === "forward" + ? projection.relationshipTypeVersion.targetDatabaseId + : projection.relationshipTypeVersion.sourceDatabaseId) + ) { + relationshipError( + "UNAVAILABLE", + "The canonical relation Property metadata is inconsistent.", + { statusCode: 503 }, + ); + } + relationshipError( + "USE_RELATIONSHIP_MUTATION", + "Canonical relation values must be changed through mutate-content-relationships.", + { statusCode: 409 }, + ); +} + +export async function readCanonicalRelationPropertyValue(args: { + propertyId: string; + pageId: string; +}): Promise<{ + status: "ready"; + scope: "viewer-accessible"; + pageIds: string[]; +}> { + const resolved = await resolveCanonicalRelationProjection(args.propertyId); + if (!resolved || resolved.projection.archivedAt) { + relationshipError( + "UNSUPPORTED_CONFIGURATION", + "The property is not an active canonical relation projection.", + ); + } + const values = await readCanonicalRelationPropertyValues({ + databaseId: resolved.projection.databaseId, + pageIds: [args.pageId], + }); + return { + status: "ready", + scope: "viewer-accessible", + pageIds: values.get(`${args.pageId}\u0000${args.propertyId}`) ?? [], + }; +} + +export async function readCanonicalRelationPropertyValues(args: { + databaseId: string; + pageIds: string[]; +}): Promise> { + const pageIds = [...new Set(args.pageIds)].sort(); + const result = new Map(); + if (pageIds.length === 0) return result; + + await loadRelationshipDatabase(args.databaseId, "viewer"); + const db = getDb(); + const projections = await db + .select({ + projection: schema.contentRelationshipProjections, + relationshipType: schema.contentRelationshipTypes, + relationshipTypeVersion: schema.contentRelationshipTypeVersions, + }) + .from(schema.contentRelationshipProjections) + .innerJoin( + schema.contentRelationshipTypes, + eq( + schema.contentRelationshipTypes.id, + schema.contentRelationshipProjections.relationshipTypeId, + ), + ) + .innerJoin( + schema.contentRelationshipTypeVersions, + eq( + schema.contentRelationshipTypeVersions.id, + schema.contentRelationshipTypes.currentVersionId, + ), + ) + .where( + and( + eq(schema.contentRelationshipProjections.databaseId, args.databaseId), + isNull(schema.contentRelationshipProjections.archivedAt), + ), + ); + const definitions = await db + .select({ + id: schema.documentPropertyDefinitions.id, + optionsJson: schema.documentPropertyDefinitions.optionsJson, + }) + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.databaseId, args.databaseId)); + const canonicalDefinitions = definitions.flatMap((definition) => { + const marker = parsePropertyOptions(definition.optionsJson).relation; + return marker?.relationshipTypeId ? [{ ...definition, marker }] : []; + }); + const projectionRows = projections.map((row) => row.projection); + for (const definition of canonicalDefinitions) { + const row = projections.find( + (candidate) => candidate.projection.propertyId === definition.id, + ); + if ( + !row || + row.projection.archivedAt || + row.projection.relationshipTypeId !== + definition.marker.relationshipTypeId || + row.projection.direction !== definition.marker.direction || + row.relationshipType.state !== "active" || + (row.projection.direction === "forward" + ? row.relationshipTypeVersion.targetDatabaseId + : row.relationshipTypeVersion.sourceDatabaseId) !== + definition.marker.databaseId + ) { + relationshipError( + "UNAVAILABLE", + "A canonical relation Property is missing its persisted definition.", + { statusCode: 503 }, + ); + } + } + const projectionsForDatabase = projectionRows.filter( + (projection) => + !projection.archivedAt && + canonicalDefinitions.some( + (definition) => definition.id === projection.propertyId, + ), + ); + if (projectionsForDatabase.length === 0) return result; + for (const pageId of pageIds) { + for (const projection of projectionsForDatabase) { + result.set(`${pageId}\u0000${projection.propertyId}`, []); + } + } + + const forwardTypeIds = projectionsForDatabase + .filter((projection) => projection.direction === "forward") + .map((projection) => projection.relationshipTypeId); + const inverseTypeIds = projectionsForDatabase + .filter((projection) => projection.direction === "inverse") + .map((projection) => projection.relationshipTypeId); + const lineages = await db + .select() + .from(schema.contentRelationshipLineages) + .where( + or( + forwardTypeIds.length + ? and( + inArray( + schema.contentRelationshipLineages.relationshipTypeId, + forwardTypeIds, + ), + inArray(schema.contentRelationshipLineages.sourcePageId, pageIds), + ) + : undefined, + inverseTypeIds.length + ? and( + inArray( + schema.contentRelationshipLineages.relationshipTypeId, + inverseTypeIds, + ), + inArray(schema.contentRelationshipLineages.targetPageId, pageIds), + ) + : undefined, + ), + ); + const activeByLineage = await activeActivationIdsForLineages( + db, + lineages.map((lineage) => lineage.id), + ); + const liveLineages = lineages.filter( + (lineage) => (activeByLineage.get(lineage.id)?.length ?? 0) > 0, + ); + if (liveLineages.length === 0) return result; + + const endpointIds = [ + ...new Set( + liveLineages.flatMap((lineage) => [ + lineage.sourcePageId, + lineage.targetPageId, + ]), + ), + ]; + const documents = await db + .select({ id: schema.documents.id, trashedAt: schema.documents.trashedAt }) + .from(schema.documents) + .where( + and( + inArray(schema.documents.id, endpointIds), + isNull(schema.documents.trashedAt), + ), + ); + const activeDocuments = new Set(documents.map((document) => document.id)); + const permanentlyDeleted = await db + .select({ pageId: schema.contentRelationshipEndpointStates.pageId }) + .from(schema.contentRelationshipEndpointStates) + .where( + and( + inArray(schema.contentRelationshipEndpointStates.pageId, endpointIds), + isNotNull( + schema.contentRelationshipEndpointStates.permanentlyDeletedAt, + ), + ), + ); + const deletedPageIds = new Set( + permanentlyDeleted.map((state) => state.pageId), + ); + const accessByPageId = new Map(); + await Promise.all( + endpointIds.map(async (pageId) => { + if (!activeDocuments.has(pageId)) { + accessByPageId.set(pageId, false); + return; + } + if (deletedPageIds.has(pageId)) { + accessByPageId.set(pageId, false); + return; + } + accessByPageId.set( + pageId, + Boolean(await resolveContentDocumentAccess(pageId)), + ); + }), + ); + + const projectionsByTypeDirection = new Map< + string, + typeof projectionsForDatabase + >(); + for (const projection of projectionsForDatabase) { + const key = `${projection.relationshipTypeId}\u0000${projection.direction}`; + projectionsByTypeDirection.set(key, [ + ...(projectionsByTypeDirection.get(key) ?? []), + projection, + ]); + } + for (const lineage of liveLineages) { + if ( + !accessByPageId.get(lineage.sourcePageId) || + !accessByPageId.get(lineage.targetPageId) + ) { + continue; + } + const forwards = + projectionsByTypeDirection.get( + `${lineage.relationshipTypeId}\u0000forward`, + ) ?? []; + if (pageIds.includes(lineage.sourcePageId)) { + for (const forward of forwards) { + const key = `${lineage.sourcePageId}\u0000${forward.propertyId}`; + result.set(key, [ + ...new Set([...(result.get(key) ?? []), lineage.targetPageId]), + ]); + } + } + const inverses = + projectionsByTypeDirection.get( + `${lineage.relationshipTypeId}\u0000inverse`, + ) ?? []; + if (pageIds.includes(lineage.targetPageId)) { + for (const inverse of inverses) { + const key = `${lineage.targetPageId}\u0000${inverse.propertyId}`; + result.set(key, [ + ...new Set([...(result.get(key) ?? []), lineage.sourcePageId]), + ]); + } + } + } + for (const ids of result.values()) ids.sort(); + return result; +} diff --git a/templates/content/actions/_relationship-core.ts b/templates/content/actions/_relationship-core.ts new file mode 100644 index 00000000000..b1ba047bea5 --- /dev/null +++ b/templates/content/actions/_relationship-core.ts @@ -0,0 +1,809 @@ +import { createHash } from "node:crypto"; + +import { + ActionContractError, + type ActionRunContext, +} from "@agent-native/core/action"; +import { + getRequestOrgId, + getRequestUserEmail, +} from "@agent-native/core/server/request-context"; +import { + accessFilter, + ROLE_RANK, + type ShareRole, +} from "@agent-native/core/sharing"; +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import type { + CanonicalRelationProjection, + RelationshipActionErrorCode, + RelationshipCapabilities, + RelationshipInvalidation, + RelationshipType, + RelationshipTypeVersion, +} from "../shared/relationships.js"; +import { resolveContentDocumentAccess } from "./_content-document-access.js"; +import { getContentOrganizationMembership } from "./_content-space-access.js"; +import { nanoid } from "./_property-utils.js"; + +export type RelationshipDb = ReturnType; + +export interface RelationshipActorContext { + userEmail: string; + orgId: string | null; + callerScope: string; + actor: { + kind: "person" | "agent" | "automation" | "programmatic"; + displayName: string; + email?: string; + runId?: string; + networkProtocol?: "a2a" | "mcp" | "provider-api"; + networkId?: string; + networkPeer?: string; + threadId?: string; + turnId?: string; + }; + authorizingPrincipal: { + kind: "user"; + email: string; + orgId: string | null; + }; + origin: string; + runId: string | null; +} + +export interface RelationshipTypeBundle { + type: typeof schema.contentRelationshipTypes.$inferSelect; + version: typeof schema.contentRelationshipTypeVersions.$inferSelect; +} + +export interface RelationshipDatabaseContext { + database: typeof schema.contentDatabases.$inferSelect; + document: typeof schema.documents.$inferSelect; + role: ShareRole | "owner"; +} + +export interface RelationshipRevisionContext { + revisionId: string; + recoveryToken: string; + eventIds: string[]; + actor: RelationshipActorContext; +} + +function canonical(value: unknown): string { + if (value === undefined) return "null"; + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + return `{${Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`) + .join(",")}}`; +} + +export function relationshipRequestHash(value: unknown): string { + return `sha256:${createHash("sha256").update(canonical(value)).digest("hex")}`; +} + +export function relationshipError( + errorCode: RelationshipActionErrorCode, + message: string, + options: { statusCode?: number; details?: Record } = {}, +): never { + throw new ActionContractError(message, { + errorCode, + statusCode: options.statusCode ?? 400, + details: options.details, + }); +} + +export function relationshipActorContext( + context?: ActionRunContext, +): RelationshipActorContext { + const userEmail = (context?.userEmail ?? getRequestUserEmail()) + ?.trim() + .toLowerCase(); + if (!userEmail) { + relationshipError("NOT_ACCESSIBLE", "Authentication is required.", { + statusCode: 401, + }); + } + const orgId = context?.orgId ?? getRequestOrgId() ?? null; + const agentCaller = context?.caller === "tool" || context?.caller === "mcp"; + const automationCaller = context?.caller === "automation"; + const caller = context?.caller ?? "frontend"; + const programmaticCaller = + context?.caller === "http" || + context?.caller === "cli" || + context?.caller === "a2a" || + context?.caller === "webmcp"; + const kind = automationCaller + ? "automation" + : agentCaller + ? "agent" + : programmaticCaller + ? "programmatic" + : "person"; + const displayName = + kind === "agent" + ? context?.networkPeer || + context?.networkId || + (context?.threadId ? `Agent ${context.threadId}` : "Agent") + : kind === "automation" + ? context?.automation?.triggerName || "Automation" + : userEmail; + return { + userEmail, + orgId, + callerScope: `${userEmail}|org:${orgId ?? "personal"}`, + actor: { + kind, + displayName, + ...(kind === "person" || kind === "programmatic" + ? { email: userEmail } + : {}), + ...(context?.runId ? { runId: context.runId } : {}), + ...(context?.networkProtocol + ? { networkProtocol: context.networkProtocol } + : {}), + ...(context?.networkId ? { networkId: context.networkId } : {}), + ...(context?.networkPeer ? { networkPeer: context.networkPeer } : {}), + ...(context?.threadId ? { threadId: context.threadId } : {}), + ...(context?.turnId ? { turnId: context.turnId } : {}), + }, + authorizingPrincipal: { kind: "user", email: userEmail, orgId }, + origin: caller, + runId: context?.runId ?? null, + }; +} + +export function encodeRelationshipCursor(offset: number): string { + return Buffer.from(JSON.stringify({ v: 1, offset }), "utf8").toString( + "base64url", + ); +} + +export function decodeRelationshipCursor(cursor: string | undefined): number { + if (!cursor) return 0; + try { + const parsed = JSON.parse( + Buffer.from(cursor, "base64url").toString("utf8"), + ) as { v?: unknown; offset?: unknown }; + if ( + parsed.v !== 1 || + typeof parsed.offset !== "number" || + !Number.isInteger(parsed.offset) || + parsed.offset < 0 + ) { + throw new Error("invalid cursor"); + } + return parsed.offset; + } catch { + relationshipError("INVALID_TARGET", "The relationship cursor is invalid."); + } +} + +export function roleAtLeast( + role: ShareRole | "owner", + minimum: ShareRole | "owner", +): boolean { + return ROLE_RANK[role] >= ROLE_RANK[minimum]; +} + +export async function resolveRelationshipDocumentAccess( + documentId: string, + options: { db?: RelationshipDb; context?: ActionRunContext } = {}, +) { + if (!options.db) return resolveContentDocumentAccess(documentId); + const db = options.db; + const actor = relationshipActorContext(options.context); + const [unscoped] = await db + .select() + .from(schema.documents) + .where(eq(schema.documents.id, documentId)); + if (!unscoped) return null; + let accessOrgId = actor.orgId; + if (unscoped.orgId) { + const membership = await getContentOrganizationMembership( + unscoped.orgId, + actor.userEmail, + { db }, + ); + accessOrgId = membership ? unscoped.orgId : null; + } + const accessContext = { + userEmail: actor.userEmail, + ...(accessOrgId ? { orgId: accessOrgId } : {}), + }; + if (unscoped.ownerEmail.toLowerCase() === actor.userEmail) { + return { role: "owner" as const, resource: unscoped }; + } + for (const role of ["admin", "editor", "commenter", "viewer"] as const) { + const [resource] = await db + .select() + .from(schema.documents) + .where( + and( + eq(schema.documents.id, documentId), + accessFilter( + schema.documents, + schema.documentShares, + accessContext, + role, + { includePublic: true }, + ), + ), + ); + if (resource) return { role, resource }; + } + return null; +} + +export async function requireRelationshipDocumentAccess( + documentId: string, + minimum: ShareRole | "owner" = "viewer", + options: { db?: RelationshipDb; context?: ActionRunContext } = {}, +) { + const access = await resolveRelationshipDocumentAccess(documentId, options); + if (!access || !roleAtLeast(access.role, minimum)) { + relationshipError( + "NOT_ACCESSIBLE", + "The requested Content object is not accessible.", + { + statusCode: 404, + }, + ); + } + return access; +} + +export async function loadRelationshipDatabase( + databaseId: string, + minimum: ShareRole | "owner" = "viewer", + db: RelationshipDb = getDb(), + context?: ActionRunContext, + options: { allowDeleted?: boolean } = {}, +): Promise { + const [database] = await db + .select() + .from(schema.contentDatabases) + .where( + options.allowDeleted + ? eq(schema.contentDatabases.id, databaseId) + : and( + eq(schema.contentDatabases.id, databaseId), + isNull(schema.contentDatabases.deletedAt), + ), + ); + if (!database?.spaceId) { + relationshipError( + "NOT_ACCESSIBLE", + "The requested Content database is not accessible.", + { + statusCode: 404, + }, + ); + } + const access = await requireRelationshipDocumentAccess( + database.documentId, + minimum, + { db, context }, + ); + return { + database, + document: access.resource, + role: access.role, + }; +} + +export async function loadRelationshipTypeBundle( + typeId: string, + options: { allowArchived?: boolean; db?: RelationshipDb } = {}, +): Promise { + const db = options.db ?? getDb(); + const [type] = await db + .select() + .from(schema.contentRelationshipTypes) + .where(eq(schema.contentRelationshipTypes.id, typeId)); + if (!type || (!options.allowArchived && type.state !== "active")) { + relationshipError( + "TYPE_UNAVAILABLE", + "The relationship type is unavailable.", + { + statusCode: 409, + }, + ); + } + const [version] = await db + .select() + .from(schema.contentRelationshipTypeVersions) + .where( + and( + eq(schema.contentRelationshipTypeVersions.id, type.currentVersionId), + eq(schema.contentRelationshipTypeVersions.relationshipTypeId, type.id), + ), + ); + if (!version) { + relationshipError( + "UNAVAILABLE", + "The relationship definition is unavailable.", + { + statusCode: 503, + }, + ); + } + if ( + version.directionalKind !== "directional" || + version.inverseCardinality !== "many" || + version.allowSelf !== 0 || + version.selectorKind !== "database" || + (version.forwardCardinality !== "one" && + version.forwardCardinality !== "many") + ) { + relationshipError( + "UNSUPPORTED_CONFIGURATION", + "This relationship configuration is not supported.", + ); + } + return { type, version }; +} + +export function relationshipTypeDto( + row: typeof schema.contentRelationshipTypes.$inferSelect, +): RelationshipType { + return { + id: row.id, + spaceId: row.spaceId, + currentVersionId: row.currentVersionId, + state: row.state === "archived" ? "archived" : "active", + provenance: "local", + archivedAt: row.archivedAt, + }; +} + +export function relationshipTypeVersionDto( + row: typeof schema.contentRelationshipTypeVersions.$inferSelect, +): RelationshipTypeVersion { + return { + id: row.id, + relationshipTypeId: row.relationshipTypeId, + version: row.version, + forwardLabel: row.forwardLabel, + inverseLabel: row.inverseLabel, + forwardCardinality: row.forwardCardinality === "one" ? "one" : "many", + inverseCardinality: "many", + sourceDatabaseId: row.sourceDatabaseId, + targetDatabaseId: row.targetDatabaseId, + directional: true, + allowSelf: false, + selectorKind: "database", + }; +} + +export function relationshipProjectionDto( + row: typeof schema.contentRelationshipProjections.$inferSelect, +): CanonicalRelationProjection { + return { + id: row.id, + propertyId: row.propertyId, + databaseId: row.databaseId, + relationshipTypeId: row.relationshipTypeId, + direction: row.direction === "inverse" ? "inverse" : "forward", + editable: row.editable === 1, + alias: row.alias, + description: row.description, + archivedAt: row.archivedAt, + }; +} + +export function relationshipCapabilities(args: { + databaseRole?: ShareRole | "owner" | null; + pageRole?: ShareRole | "owner" | null; + direction?: "forward" | "inverse"; + editable?: boolean; + cardinality?: "one" | "many"; +}): RelationshipCapabilities { + const canConfigure = Boolean( + args.databaseRole && roleAtLeast(args.databaseRole, "admin"), + ); + const rowEditable = Boolean( + args.databaseRole && + roleAtLeast(args.databaseRole, "editor") && + args.pageRole && + roleAtLeast(args.pageRole, "editor"), + ); + const canEditInverse = + args.direction === "inverse" && args.editable === true && rowEditable; + const canMutate = args.direction === "inverse" ? canEditInverse : rowEditable; + return { + canConfigure, + canAdd: canMutate, + canRemove: canMutate, + canReplace: canMutate && args.cardinality === "one", + canEditInverse, + }; +} + +export function emptyRelationshipInvalidation(): RelationshipInvalidation { + return { + pageIds: [], + databaseIds: [], + propertyIds: [], + relationshipTypeIds: [], + }; +} + +export function mergeRelationshipInvalidation( + target: RelationshipInvalidation, + patch: Partial, +): RelationshipInvalidation { + for (const key of [ + "pageIds", + "databaseIds", + "propertyIds", + "relationshipTypeIds", + ] as const) { + target[key] = [...new Set([...target[key], ...(patch[key] ?? [])])].sort(); + } + return target; +} + +export async function lockRelationshipTypes( + tx: RelationshipDb, + typeIds: string[], +): Promise { + const ids = [...new Set(typeIds)].sort(); + for (const id of ids) { + await tx + .update(schema.contentRelationshipTypes) + .set({ updatedAt: sql`${schema.contentRelationshipTypes.updatedAt}` }) + .where(eq(schema.contentRelationshipTypes.id, id)) + .returning({ id: schema.contentRelationshipTypes.id }); + } +} + +export async function lockRelationshipLineages( + tx: RelationshipDb, + lineageIds: string[], +): Promise { + const ids = [...new Set(lineageIds)].sort(); + for (const id of ids) { + await tx + .update(schema.contentRelationshipLineages) + .set({ updatedAt: sql`${schema.contentRelationshipLineages.updatedAt}` }) + .where(eq(schema.contentRelationshipLineages.id, id)) + .returning({ id: schema.contentRelationshipLineages.id }); + } +} + +export async function createRelationshipRevision( + tx: RelationshipDb, + args: { + tenant: { ownerEmail: string; orgId: string | null; spaceId: string }; + operationId: string; + operation: string; + diff: Record; + context?: ActionRunContext; + compensatesRevisionId?: string | null; + }, +): Promise { + const actor = relationshipActorContext(args.context); + const revisionId = nanoid(24); + const recoveryToken = nanoid(32); + await tx.insert(schema.contentRelationshipRevisions).values({ + id: revisionId, + ownerEmail: args.tenant.ownerEmail, + orgId: args.tenant.orgId, + spaceId: args.tenant.spaceId, + operationId: args.operationId, + operation: args.operation, + actorJson: JSON.stringify(actor.actor), + authorizingPrincipalJson: JSON.stringify(actor.authorizingPrincipal), + origin: actor.origin, + recoveryToken, + diffJson: JSON.stringify(args.diff), + compensatesRevisionId: args.compensatesRevisionId ?? null, + }); + return { revisionId, recoveryToken, eventIds: [], actor }; +} + +export async function appendRelationshipEvent( + tx: RelationshipDb, + revision: RelationshipRevisionContext, + args: { + tenant: { ownerEmail: string; orgId: string | null; spaceId: string }; + kind: string; + relationshipTypeId?: string | null; + relationshipTypeVersionId?: string | null; + route?: unknown; + targets?: unknown; + diff?: unknown; + eventId?: string; + }, +): Promise { + const eventId = args.eventId ?? nanoid(24); + await tx.insert(schema.contentRelationshipEvents).values({ + id: eventId, + ownerEmail: args.tenant.ownerEmail, + orgId: args.tenant.orgId, + spaceId: args.tenant.spaceId, + revisionId: revision.revisionId, + sequence: revision.eventIds.length, + relationshipTypeId: args.relationshipTypeId ?? null, + relationshipTypeVersionId: args.relationshipTypeVersionId ?? null, + kind: args.kind, + actorJson: JSON.stringify(revision.actor.actor), + authorizingPrincipalJson: JSON.stringify( + revision.actor.authorizingPrincipal, + ), + origin: revision.actor.origin, + runId: revision.actor.runId, + routeJson: JSON.stringify(args.route ?? {}), + targetsJson: JSON.stringify(args.targets ?? {}), + diffJson: JSON.stringify(args.diff ?? {}), + }); + revision.eventIds.push(eventId); + return eventId; +} + +export async function replayRelationshipReceipt( + tx: RelationshipDb, + args: { + spaceId: string; + operationId: string; + requestHash: string; + context?: ActionRunContext; + }, +): Promise { + const actor = relationshipActorContext(args.context); + const [receipt] = await tx + .select() + .from(schema.contentRelationshipReceipts) + .where( + and( + eq(schema.contentRelationshipReceipts.spaceId, args.spaceId), + eq(schema.contentRelationshipReceipts.callerScope, actor.callerScope), + eq(schema.contentRelationshipReceipts.operationId, args.operationId), + ), + ); + if (!receipt) return null; + if (receipt.requestHash !== args.requestHash) { + relationshipError( + "IDEMPOTENCY_CONFLICT", + "This operation ID was already used with different relationship changes.", + { statusCode: 409 }, + ); + } + try { + return JSON.parse(receipt.resultJson) as T; + } catch { + relationshipError( + "UNAVAILABLE", + "The committed relationship receipt is unreadable.", + { + statusCode: 503, + }, + ); + } +} + +export async function lockRelationshipOperation( + tx: RelationshipDb, + args: { + tenant: { ownerEmail: string; orgId: string | null; spaceId: string }; + operationId: string; + context?: ActionRunContext; + }, +): Promise { + const actor = relationshipActorContext(args.context); + const id = relationshipRequestHash({ + spaceId: args.tenant.spaceId, + callerScope: actor.callerScope, + operationId: args.operationId, + }); + await tx + .insert(schema.contentRelationshipOperationLocks) + .values({ + id, + ownerEmail: args.tenant.ownerEmail, + orgId: args.tenant.orgId, + spaceId: args.tenant.spaceId, + callerScope: actor.callerScope, + operationId: args.operationId, + }) + .onConflictDoNothing(); + await tx + .update(schema.contentRelationshipOperationLocks) + .set({ + updatedAt: sql`${schema.contentRelationshipOperationLocks.updatedAt}`, + }) + .where(eq(schema.contentRelationshipOperationLocks.id, id)) + .returning({ id: schema.contentRelationshipOperationLocks.id }); +} + +export async function insertRelationshipReceipt( + tx: RelationshipDb, + args: { + id: string; + tenant: { ownerEmail: string; orgId: string | null; spaceId: string }; + operationId: string; + requestHash: string; + revisionId: string; + result: unknown; + context?: ActionRunContext; + }, +): Promise { + const actor = relationshipActorContext(args.context); + await tx.insert(schema.contentRelationshipReceipts).values({ + id: args.id, + ownerEmail: args.tenant.ownerEmail, + orgId: args.tenant.orgId, + spaceId: args.tenant.spaceId, + callerScope: actor.callerScope, + operationId: args.operationId, + requestHash: args.requestHash, + revisionId: args.revisionId, + resultJson: JSON.stringify(args.result), + }); +} + +export function relationshipTenant( + database: typeof schema.contentDatabases.$inferSelect, +) { + if (!database.spaceId) { + relationshipError("INVALID_TARGET", "The database has no Content space."); + } + return { + ownerEmail: database.ownerEmail, + orgId: database.orgId, + spaceId: database.spaceId, + }; +} + +export function assertSameRelationshipTenant( + left: typeof schema.contentDatabases.$inferSelect, + right: typeof schema.contentDatabases.$inferSelect, +): void { + const leftAuthority = left.orgId + ? `org:${left.orgId}` + : `owner:${left.ownerEmail.toLowerCase()}`; + const rightAuthority = right.orgId + ? `org:${right.orgId}` + : `owner:${right.ownerEmail.toLowerCase()}`; + if ( + !left.spaceId || + left.spaceId !== right.spaceId || + leftAuthority !== rightAuthority + ) { + relationshipError( + "INVALID_TARGET", + "Relationship endpoints must belong to the same Content space and tenant.", + ); + } +} + +export async function activeActivationIdsForLineages( + db: RelationshipDb, + lineageIds: string[], +): Promise> { + const result = new Map(); + if (lineageIds.length === 0) return result; + const rows = await db + .select({ + id: schema.contentRelationshipActivations.id, + lineageId: schema.contentRelationshipActivations.lineageId, + retiredId: schema.contentRelationshipActivationRetirements.id, + }) + .from(schema.contentRelationshipActivations) + .leftJoin( + schema.contentRelationshipActivationRetirements, + eq( + schema.contentRelationshipActivationRetirements.activationId, + schema.contentRelationshipActivations.id, + ), + ) + .where( + inArray(schema.contentRelationshipActivations.lineageId, lineageIds), + ); + for (const row of rows) { + if (row.retiredId) continue; + const current = result.get(row.lineageId) ?? []; + current.push(row.id); + result.set(row.lineageId, current); + } + for (const ids of result.values()) ids.sort(); + return result; +} + +export async function retireRelationshipActivations( + tx: RelationshipDb, + args: { + activationIds: string[]; + eventId: string; + tenant: { ownerEmail: string; orgId: string | null; spaceId: string }; + actorEmail: string; + }, +): Promise { + if (args.activationIds.length === 0) return []; + const active = await tx + .select({ id: schema.contentRelationshipActivations.id }) + .from(schema.contentRelationshipActivations) + .leftJoin( + schema.contentRelationshipActivationRetirements, + eq( + schema.contentRelationshipActivationRetirements.activationId, + schema.contentRelationshipActivations.id, + ), + ) + .where( + and( + inArray(schema.contentRelationshipActivations.id, args.activationIds), + isNull(schema.contentRelationshipActivationRetirements.id), + ), + ); + for (const activation of active) { + await tx + .insert(schema.contentRelationshipActivationRetirements) + .values({ + id: nanoid(24), + ownerEmail: args.tenant.ownerEmail, + orgId: args.tenant.orgId, + spaceId: args.tenant.spaceId, + activationId: activation.id, + removedEventId: args.eventId, + removedBy: args.actorEmail, + }) + .onConflictDoNothing(); + } + return active.map((activation) => activation.id).sort(); +} + +export async function lockRelationshipCardinalitySlots( + tx: RelationshipDb, + slots: Array<{ + ownerEmail: string; + orgId: string | null; + spaceId: string; + relationshipTypeId: string; + sourcePageId: string; + }>, +): Promise { + const unique = [ + ...new Map( + slots.map((slot) => [ + `${slot.relationshipTypeId}\u0000${slot.sourcePageId}`, + slot, + ]), + ).values(), + ].sort((left, right) => + `${left.relationshipTypeId}\u0000${left.sourcePageId}`.localeCompare( + `${right.relationshipTypeId}\u0000${right.sourcePageId}`, + ), + ); + for (const slot of unique) { + await tx + .insert(schema.contentRelationshipCardinalitySlots) + .values({ id: nanoid(18), ...slot }) + .onConflictDoNothing(); + await tx + .update(schema.contentRelationshipCardinalitySlots) + .set({ + updatedAt: sql`${schema.contentRelationshipCardinalitySlots.updatedAt}`, + }) + .where( + and( + eq( + schema.contentRelationshipCardinalitySlots.relationshipTypeId, + slot.relationshipTypeId, + ), + eq( + schema.contentRelationshipCardinalitySlots.sourcePageId, + slot.sourcePageId, + ), + ), + ) + .returning({ id: schema.contentRelationshipCardinalitySlots.id }); + } +} diff --git a/templates/content/actions/_relationship-lifecycle.ts b/templates/content/actions/_relationship-lifecycle.ts new file mode 100644 index 00000000000..46967e9aa98 --- /dev/null +++ b/templates/content/actions/_relationship-lifecycle.ts @@ -0,0 +1,252 @@ +import type { ActionRunContext } from "@agent-native/core/action"; +import { and, eq, inArray, isNotNull, or } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { + appendRelationshipEvent, + createRelationshipRevision, + lockRelationshipLineages, + lockRelationshipTypes, + relationshipError, + type RelationshipDb, +} from "./_relationship-core.js"; + +export interface RelationshipDocumentLifecycleResult { + revisionId: string | null; + eventIds: string[]; + incidentTypeIds: string[]; + incidentPageIds: string[]; +} + +export async function getDocumentRelationshipLifecycleImpact(args: { + documentIds: string[]; + db?: RelationshipDb; +}): Promise<{ + incidentTypeIds: string[]; + incidentPageIds: string[]; + lineageIds: string[]; +}> { + const db = args.db ?? getDb(); + const documentIds = [...new Set(args.documentIds)].sort(); + if (documentIds.length === 0) { + return { incidentTypeIds: [], incidentPageIds: [], lineageIds: [] }; + } + const lineages = await db + .select({ + id: schema.contentRelationshipLineages.id, + relationshipTypeId: schema.contentRelationshipLineages.relationshipTypeId, + sourcePageId: schema.contentRelationshipLineages.sourcePageId, + targetPageId: schema.contentRelationshipLineages.targetPageId, + }) + .from(schema.contentRelationshipLineages) + .where( + or( + inArray(schema.contentRelationshipLineages.sourcePageId, documentIds), + inArray(schema.contentRelationshipLineages.targetPageId, documentIds), + ), + ); + return { + incidentTypeIds: [ + ...new Set(lineages.map((lineage) => lineage.relationshipTypeId)), + ].sort(), + incidentPageIds: [ + ...new Set( + lineages.flatMap((lineage) => [ + lineage.sourcePageId, + lineage.targetPageId, + ]), + ), + ].sort(), + lineageIds: lineages.map((lineage) => lineage.id).sort(), + }; +} + +export async function assertDocumentRelationshipPermanentDeleteAllowed( + db: RelationshipDb, + args: { documentIds: string[] }, +): Promise { + const documentIds = [...new Set(args.documentIds)].sort(); + if (documentIds.length === 0) return; + const [alreadyDeleted] = await db + .select({ pageId: schema.contentRelationshipEndpointStates.pageId }) + .from(schema.contentRelationshipEndpointStates) + .where( + and( + inArray(schema.contentRelationshipEndpointStates.pageId, documentIds), + isNotNull( + schema.contentRelationshipEndpointStates.permanentlyDeletedAt, + ), + ), + ) + .limit(1); + if (alreadyDeleted) { + relationshipError( + "STALE_RECOVERY", + "A permanently deleted relationship endpoint cannot be restored.", + { statusCode: 409 }, + ); + } +} + +export async function applyRelationshipDocumentLifecycleInsideTransaction( + db: RelationshipDb, + args: { + documentIds: string[]; + operation: "trash" | "restore" | "permanent-delete"; + operationId: string; + context?: ActionRunContext; + }, +): Promise { + const documentIds = [...new Set(args.documentIds)].sort(); + if (documentIds.length === 0) { + return { + revisionId: null, + eventIds: [], + incidentTypeIds: [], + incidentPageIds: [], + }; + } + if (args.operation === "restore") { + const [permanentlyDeleted] = await db + .select({ pageId: schema.contentRelationshipEndpointStates.pageId }) + .from(schema.contentRelationshipEndpointStates) + .where( + and( + inArray(schema.contentRelationshipEndpointStates.pageId, documentIds), + isNotNull( + schema.contentRelationshipEndpointStates.permanentlyDeletedAt, + ), + ), + ) + .limit(1); + if (permanentlyDeleted) { + relationshipError( + "STALE_RECOVERY", + "A permanently deleted relationship endpoint cannot be restored.", + { statusCode: 409 }, + ); + } + } + + const lineages = await db + .select() + .from(schema.contentRelationshipLineages) + .where( + or( + inArray(schema.contentRelationshipLineages.sourcePageId, documentIds), + inArray(schema.contentRelationshipLineages.targetPageId, documentIds), + ), + ); + if (lineages.length === 0) { + return { + revisionId: null, + eventIds: [], + incidentTypeIds: [], + incidentPageIds: [], + }; + } + const spaces = new Set(lineages.map((lineage) => lineage.spaceId)); + if (spaces.size !== 1) { + relationshipError( + "UNAVAILABLE", + "The relationship lifecycle scope crosses Content spaces.", + { statusCode: 503 }, + ); + } + const first = lineages[0]!; + const tenant = { + ownerEmail: first.ownerEmail, + orgId: first.orgId, + spaceId: first.spaceId, + }; + const typeIds = [ + ...new Set(lineages.map((lineage) => lineage.relationshipTypeId)), + ].sort(); + await lockRelationshipTypes(db, typeIds); + await lockRelationshipLineages( + db, + lineages.map((lineage) => lineage.id), + ); + + const now = new Date().toISOString(); + if (args.operation === "permanent-delete") { + const affectedDocumentIds = documentIds.filter((documentId) => + lineages.some( + (lineage) => + lineage.sourcePageId === documentId || + lineage.targetPageId === documentId, + ), + ); + for (const pageId of affectedDocumentIds) { + await db + .insert(schema.contentRelationshipEndpointStates) + .values({ + pageId, + ownerEmail: tenant.ownerEmail, + orgId: tenant.orgId, + spaceId: tenant.spaceId, + permanentlyDeletedAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: schema.contentRelationshipEndpointStates.pageId, + set: { permanentlyDeletedAt: now, updatedAt: now }, + }); + } + } + + const revision = await createRelationshipRevision(db, { + tenant, + operationId: args.operationId, + operation: `document-${args.operation}`, + diff: { + operation: args.operation, + documentIds, + lineageIds: lineages.map((lineage) => lineage.id).sort(), + }, + context: args.context, + }); + const versions = await db + .select({ + typeId: schema.contentRelationshipTypes.id, + versionId: schema.contentRelationshipTypes.currentVersionId, + }) + .from(schema.contentRelationshipTypes) + .where(inArray(schema.contentRelationshipTypes.id, typeIds)); + const versionByTypeId = new Map( + versions.map((version) => [version.typeId, version.versionId]), + ); + for (const lineage of [...lineages].sort((a, b) => + a.id.localeCompare(b.id), + )) { + await appendRelationshipEvent(db, revision, { + tenant, + kind: `relationship-endpoint-${args.operation}`, + relationshipTypeId: lineage.relationshipTypeId, + relationshipTypeVersionId: + versionByTypeId.get(lineage.relationshipTypeId) ?? null, + targets: { + lineageId: lineage.id, + sourcePageId: lineage.sourcePageId, + targetPageId: lineage.targetPageId, + affectedPageIds: documentIds.filter( + (id) => id === lineage.sourcePageId || id === lineage.targetPageId, + ), + }, + diff: { state: args.operation }, + }); + } + return { + revisionId: revision.revisionId, + eventIds: revision.eventIds, + incidentTypeIds: typeIds, + incidentPageIds: [ + ...new Set( + lineages.flatMap((lineage) => [ + lineage.sourcePageId, + lineage.targetPageId, + ]), + ), + ].sort(), + }; +} diff --git a/templates/content/actions/_relationship-read.ts b/templates/content/actions/_relationship-read.ts new file mode 100644 index 00000000000..1f1a117209b --- /dev/null +++ b/templates/content/actions/_relationship-read.ts @@ -0,0 +1,419 @@ +import { + isActionContractError, + type ActionRunContext, +} from "@agent-native/core/action"; +import { and, eq, inArray, isNotNull, isNull, or } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import type { + ContentRelationshipItem, + ListContentRelationshipsInput, + ListContentRelationshipsResult, + RelationshipRouteRef, +} from "../shared/relationships.js"; +import { nanoid } from "./_property-utils.js"; +import { authorizeRelationshipRoute } from "./_relationship-authority.js"; +import { + activeActivationIdsForLineages, + decodeRelationshipCursor, + encodeRelationshipCursor, + loadRelationshipDatabase, + loadRelationshipTypeBundle, + relationshipActorContext, + relationshipError, + resolveRelationshipDocumentAccess, + type RelationshipDb, + type RelationshipTypeBundle, +} from "./_relationship-core.js"; + +const OBSERVATION_TTL_MS = 15 * 60 * 1_000; + +export async function issueRelationshipObservation( + db: RelationshipDb, + args: { + kind: "edge" | "slot"; + edgeId?: string | null; + relationshipTypeId: string; + sourcePageId: string; + activationIds: string[]; + tenant: { ownerEmail: string; orgId: string | null; spaceId: string }; + context?: ActionRunContext; + }, +): Promise { + const actor = relationshipActorContext(args.context); + const token = nanoid(32); + await db.insert(schema.contentRelationshipObservations).values({ + token, + ownerEmail: args.tenant.ownerEmail, + orgId: args.tenant.orgId, + spaceId: args.tenant.spaceId, + callerScope: actor.callerScope, + kind: args.kind, + edgeId: args.edgeId ?? null, + relationshipTypeId: args.relationshipTypeId, + sourcePageId: args.sourcePageId, + activationIdsJson: JSON.stringify([...new Set(args.activationIds)].sort()), + expiresAt: new Date(Date.now() + OBSERVATION_TTL_MS).toISOString(), + }); + return token; +} + +export async function availableRelationshipRoutes( + db: RelationshipDb, + args: { + bundle: RelationshipTypeBundle; + sourcePageId: string; + targetPageId: string; + context?: ActionRunContext; + }, +): Promise { + const candidateRoutes: RelationshipRouteRef[] = [ + { kind: "connections-forward", sourcePageId: args.sourcePageId }, + ]; + const projections = await db + .select() + .from(schema.contentRelationshipProjections) + .where( + and( + eq( + schema.contentRelationshipProjections.relationshipTypeId, + args.bundle.type.id, + ), + isNull(schema.contentRelationshipProjections.archivedAt), + ), + ); + for (const projection of projections) { + candidateRoutes.push( + projection.direction === "inverse" + ? { + kind: "inverse-property", + propertyId: projection.propertyId, + targetPageId: args.targetPageId, + } + : { + kind: "forward-property", + propertyId: projection.propertyId, + sourcePageId: args.sourcePageId, + }, + ); + } + const result: RelationshipRouteRef[] = []; + for (const route of candidateRoutes) { + try { + await authorizeRelationshipRoute({ + db, + bundle: args.bundle, + sourcePageId: args.sourcePageId, + targetPageId: args.targetPageId, + route, + operation: "remove", + context: args.context, + }); + result.push(route); + } catch (error) { + // A route is omitted rather than returning a denied capability. Ambient + // reads must not reveal an inaccessible projection or endpoint. + if ( + !isActionContractError(error) || + ![ + "NOT_ACCESSIBLE", + "ROUTE_NOT_AUTHORIZED", + "SOURCE_AUTHORITY_UNSUPPORTED", + ].includes(error.errorCode) + ) { + throw error; + } + } + } + return result; +} + +export async function listContentRelationships( + input: ListContentRelationshipsInput, + context?: ActionRunContext, +): Promise { + const db = getDb(); + let anchorPageIds: string[]; + if (input.pageId) { + const access = await resolveRelationshipDocumentAccess(input.pageId, { + db, + context, + }); + if (!access) { + relationshipError( + "NOT_ACCESSIBLE", + "The requested Content Page is not accessible.", + { statusCode: 404 }, + ); + } + anchorPageIds = [input.pageId]; + } else { + const database = await loadRelationshipDatabase( + input.databaseId!, + "viewer", + db, + ); + anchorPageIds = ( + await db + .select({ documentId: schema.contentDatabaseItems.documentId }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, database.database.id)) + ).map((membership) => membership.documentId); + } + if (input.oppositePageId) { + const oppositeAccess = await resolveRelationshipDocumentAccess( + input.oppositePageId, + { db, context }, + ); + if (!oppositeAccess) { + relationshipError( + "NOT_ACCESSIBLE", + "The requested opposite Content Page is not accessible.", + { statusCode: 404 }, + ); + } + } + if (anchorPageIds.length === 0) { + return { scope: "viewer-accessible", items: [], nextCursor: null }; + } + const outgoing = input.direction === "outgoing" || input.direction === "both"; + const incoming = input.direction === "incoming" || input.direction === "both"; + const lineages = await db + .select() + .from(schema.contentRelationshipLineages) + .where( + and( + input.relationshipTypeId + ? eq( + schema.contentRelationshipLineages.relationshipTypeId, + input.relationshipTypeId, + ) + : undefined, + input.oppositePageId + ? or( + eq( + schema.contentRelationshipLineages.sourcePageId, + input.oppositePageId, + ), + eq( + schema.contentRelationshipLineages.targetPageId, + input.oppositePageId, + ), + ) + : undefined, + or( + outgoing + ? inArray( + schema.contentRelationshipLineages.sourcePageId, + anchorPageIds, + ) + : undefined, + incoming + ? inArray( + schema.contentRelationshipLineages.targetPageId, + anchorPageIds, + ) + : undefined, + ), + ), + ); + const activeByLineage = await activeActivationIdsForLineages( + db, + lineages.map((lineage) => lineage.id), + ); + const liveLineages = lineages.filter( + (lineage) => (activeByLineage.get(lineage.id)?.length ?? 0) > 0, + ); + if (liveLineages.length === 0) { + return { scope: "viewer-accessible", items: [], nextCursor: null }; + } + const endpointIds = [ + ...new Set( + liveLineages.flatMap((lineage) => [ + lineage.sourcePageId, + lineage.targetPageId, + ]), + ), + ]; + const deletedStates = await db + .select({ pageId: schema.contentRelationshipEndpointStates.pageId }) + .from(schema.contentRelationshipEndpointStates) + .where( + and( + inArray(schema.contentRelationshipEndpointStates.pageId, endpointIds), + isNotNull( + schema.contentRelationshipEndpointStates.permanentlyDeletedAt, + ), + ), + ); + const deletedIds = new Set(deletedStates.map((state) => state.pageId)); + const accessByPageId = new Map(); + const documentById = new Map(); + for (const pageId of endpointIds) { + const access = deletedIds.has(pageId) + ? null + : await resolveRelationshipDocumentAccess(pageId, { db, context }); + accessByPageId.set(pageId, Boolean(access)); + if (access) documentById.set(pageId, access.resource); + } + const bundleByTypeId = new Map(); + for (const typeId of [ + ...new Set(liveLineages.map((lineage) => lineage.relationshipTypeId)), + ]) { + const bundle = await loadRelationshipTypeBundle(typeId, { + allowArchived: true, + db, + }); + try { + await Promise.all([ + loadRelationshipDatabase( + bundle.version.sourceDatabaseId, + "viewer", + db, + context, + { allowDeleted: true }, + ), + loadRelationshipDatabase( + bundle.version.targetDatabaseId, + "viewer", + db, + context, + { allowDeleted: true }, + ), + ]); + } catch (error) { + if ( + isActionContractError(error) && + error.errorCode === "NOT_ACCESSIBLE" + ) { + continue; + } + throw error; + } + bundleByTypeId.set(typeId, bundle); + } + const anchorSet = new Set(anchorPageIds); + const authorized: Array<{ + lineage: (typeof liveLineages)[number]; + bundle: RelationshipTypeBundle; + direction: "outgoing" | "incoming"; + routes: RelationshipRouteRef[]; + }> = []; + for (const lineage of liveLineages) { + if ( + !accessByPageId.get(lineage.sourcePageId) || + !accessByPageId.get(lineage.targetPageId) + ) { + continue; + } + const bundle = bundleByTypeId.get(lineage.relationshipTypeId); + if (!bundle) continue; + const isOutgoing = anchorSet.has(lineage.sourcePageId); + const direction = isOutgoing ? "outgoing" : "incoming"; + if ( + input.oppositePageId && + (isOutgoing + ? lineage.targetPageId !== input.oppositePageId + : lineage.sourcePageId !== input.oppositePageId) + ) { + continue; + } + authorized.push({ + lineage, + bundle, + direction, + routes: await availableRelationshipRoutes(db, { + bundle, + sourcePageId: lineage.sourcePageId, + targetPageId: lineage.targetPageId, + context, + }), + }); + } + authorized.sort((left, right) => + left.lineage.id.localeCompare(right.lineage.id), + ); + const offset = decodeRelationshipCursor(input.cursor); + const page = authorized.slice(offset, offset + input.limit); + const items: ContentRelationshipItem[] = []; + for (const entry of page) { + const { lineage, bundle, direction } = entry; + const activationIds = activeByLineage.get(lineage.id) ?? []; + const tenant = { + ownerEmail: bundle.type.ownerEmail, + orgId: bundle.type.orgId, + spaceId: bundle.type.spaceId, + }; + const observationToken = await issueRelationshipObservation(db, { + kind: "edge", + edgeId: lineage.id, + relationshipTypeId: lineage.relationshipTypeId, + sourcePageId: lineage.sourcePageId, + activationIds, + tenant, + context, + }); + let slotObservationToken: string | null = null; + if (bundle.version.forwardCardinality === "one") { + const slotLineages = liveLineages.filter( + (candidate) => + candidate.relationshipTypeId === lineage.relationshipTypeId && + candidate.sourcePageId === lineage.sourcePageId, + ); + slotObservationToken = await issueRelationshipObservation(db, { + kind: "slot", + relationshipTypeId: lineage.relationshipTypeId, + sourcePageId: lineage.sourcePageId, + activationIds: slotLineages.flatMap( + (candidate) => activeByLineage.get(candidate.id) ?? [], + ), + tenant, + context, + }); + } + const source = documentById.get(lineage.sourcePageId)!; + const target = documentById.get(lineage.targetPageId)!; + items.push({ + edgeId: lineage.id, + lineageId: lineage.id, + typeId: lineage.relationshipTypeId, + typeVersionId: bundle.version.id, + sourcePageId: lineage.sourcePageId, + targetPageId: lineage.targetPageId, + direction, + state: source.trashedAt || target.trashedAt ? "suspended" : "active", + observedActivationIds: activationIds, + observationToken, + slotObservationToken, + source: { + pageId: source.id, + title: source.title, + state: source.trashedAt ? "trashed" : "active", + }, + target: { + pageId: target.id, + title: target.title, + state: target.trashedAt ? "trashed" : "active", + }, + relationship: { + forwardLabel: bundle.version.forwardLabel, + inverseLabel: bundle.version.inverseLabel, + label: + direction === "outgoing" + ? bundle.version.forwardLabel + : bundle.version.inverseLabel, + forwardCardinality: + bundle.version.forwardCardinality === "one" ? "one" : "many", + }, + routes: entry.routes, + }); + } + return { + scope: "viewer-accessible", + items, + nextCursor: + offset + page.length < authorized.length + ? encodeRelationshipCursor(offset + page.length) + : null, + }; +} diff --git a/templates/content/actions/bind-content-database-source-field.db.test.ts b/templates/content/actions/bind-content-database-source-field.db.test.ts index c22a5af8078..1cfa960f678 100644 --- a/templates/content/actions/bind-content-database-source-field.db.test.ts +++ b/templates/content/actions/bind-content-database-source-field.db.test.ts @@ -329,6 +329,55 @@ async function seedStaleBuilderTopicsSnapshot(rowCount = 2) { } describe("bind-content-database-source-field (row-union)", () => { + it("rejects canonical relationship projections before bind or unbind value writes", async () => { + const f = await seedRowUnion(); + const db = getDb(); + const canonicalOptionsJson = JSON.stringify({ + relation: { relationshipTypeId: "relationship-type" }, + }); + await db + .update(schema.documentPropertyDefinitions) + .set({ optionsJson: canonicalOptionsJson }) + .where(eq(schema.documentPropertyDefinitions.id, f.tagPropertyId)); + + await expect( + asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: f.tagPropertyId, + }), + ), + ).rejects.toMatchObject({ errorCode: "USE_RELATIONSHIP_MUTATION" }); + + await db + .update(schema.documentPropertyDefinitions) + .set({ optionsJson: "{}" }) + .where(eq(schema.documentPropertyDefinitions.id, f.tagPropertyId)); + await asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: f.tagPropertyId, + }), + ); + await db + .update(schema.documentPropertyDefinitions) + .set({ optionsJson: canonicalOptionsJson }) + .where(eq(schema.documentPropertyDefinitions.id, f.tagPropertyId)); + + await expect( + asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: null, + }), + ), + ).rejects.toMatchObject({ errorCode: "USE_RELATIONSHIP_MUTATION" }); + await expect(tagValue(f.docs.a1, f.tagPropertyId)).resolves.toBe("Alpha"); + }); + it("fails closed when the source field disappears before the bind update", async () => { const f = await seedRowUnion(); const triggerName = `delete_bound_field_${counter}`; diff --git a/templates/content/actions/bind-content-database-source-field.ts b/templates/content/actions/bind-content-database-source-field.ts index 7bdc12c6ee6..9a0019ae3e6 100644 --- a/templates/content/actions/bind-content-database-source-field.ts +++ b/templates/content/actions/bind-content-database-source-field.ts @@ -13,6 +13,7 @@ import { type DocumentPropertyType, } from "../shared/properties.js"; import { chunks } from "./_batch-utils.js"; +import { assertNotCanonicalRelationProjection } from "./_canonical-relation-guard.js"; import { resolveDatabaseForSourceMutation } from "./_database-source-utils.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; import { nanoid } from "./_property-utils.js"; @@ -104,6 +105,26 @@ export default defineAction({ ); } if (lockedField.propertyId) { + const [lockedProperty] = await tx + .select({ + optionsJson: schema.documentPropertyDefinitions.optionsJson, + }) + .from(schema.documentPropertyDefinitions) + .where( + and( + eq( + schema.documentPropertyDefinitions.id, + lockedField.propertyId, + ), + eq(schema.documentPropertyDefinitions.databaseId, database.id), + ), + ); + if (lockedProperty) { + assertNotCanonicalRelationProjection( + lockedProperty, + "Canonical relationship projections cannot be unbound from source fields. Use the relationship actions instead.", + ); + } const sourceRows = await tx .select({ documentId: schema.contentDatabaseSourceRows.documentId, @@ -176,6 +197,10 @@ export default defineAction({ if (!property) { throw new Error("Target column does not belong to this database."); } + assertNotCanonicalRelationProjection( + property, + "Canonical relationship projections cannot be bound to source fields. Use the relationship actions instead.", + ); if (property.systemRole) { throw new Error("System properties cannot be bound to source fields."); } @@ -291,6 +316,10 @@ export default defineAction({ "Target column changed or was deleted before the source field could be bound.", ); } + assertNotCanonicalRelationProjection( + lockedProperty, + "Canonical relationship projections cannot be bound to source fields. Use the relationship actions instead.", + ); const [lockedField] = await tx .select() diff --git a/templates/content/actions/canonical-relation-integration.db.test.ts b/templates/content/actions/canonical-relation-integration.db.test.ts new file mode 100644 index 00000000000..654ecb59826 --- /dev/null +++ b/templates/content/actions/canonical-relation-integration.db.test.ts @@ -0,0 +1,498 @@ +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runWithRequestContext } from "@agent-native/core/server"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const databasePath = join( + tmpdir(), + `canonical-relation-integration-${process.pid}-${Date.now()}.pglite`, +); +const owner = "relationship-owner@example.com"; +const viewer = "relationship-viewer@example.com"; +const spaceId = "relationship-integration-space"; +let dbModule: typeof import("../server/db/index.js"); +let configure: typeof import("./configure-content-relation-property.js").default; +let mutate: typeof import("./mutate-content-relationships.js").default; +let list: typeof import("./list-content-relationships.js").default; +let setProperty: typeof import("./set-document-property.js").default; +let deleteProperty: typeof import("./delete-document-property.js").default; +let duplicateProperty: typeof import("./duplicate-document-property.js").default; +let configureProperty: typeof import("./configure-document-property.js").default; +let listProperties: typeof import("./_property-utils.js").listPropertiesForDatabaseDocuments; +let nextId = 0; + +const asOwner = (run: () => Promise) => + runWithRequestContext({ userEmail: owner }, run); + +beforeAll(async () => { + process.env.DATABASE_URL = `pglite:${databasePath}`; + dbModule = await import("../server/db/index.js"); + const plugin = (await import("../server/plugins/db.js")).default; + await plugin(undefined as never); + configure = (await import("./configure-content-relation-property.js")) + .default; + mutate = (await import("./mutate-content-relationships.js")).default; + list = (await import("./list-content-relationships.js")).default; + setProperty = (await import("./set-document-property.js")).default; + deleteProperty = (await import("./delete-document-property.js")).default; + duplicateProperty = (await import("./duplicate-document-property.js")) + .default; + configureProperty = (await import("./configure-document-property.js")) + .default; + listProperties = (await import("./_property-utils.js")) + .listPropertiesForDatabaseDocuments; + await dbModule.getDb().insert(dbModule.schema.contentSpaces).values({ + id: spaceId, + name: "Relationship integration", + kind: "personal", + ownerEmail: owner, + filesDatabaseId: "integration-files", + createdBy: owner, + }); + await dbModule.getDb().insert(dbModule.schema.documents).values({ + id: "integration-files-page", + spaceId, + ownerEmail: owner, + title: "Files", + }); + await dbModule.getDb().insert(dbModule.schema.contentDatabases).values({ + id: "integration-files", + documentId: "integration-files-page", + spaceId, + ownerEmail: owner, + title: "Files", + systemRole: "files", + blocksSeeded: 1, + }); +}); + +afterAll(() => rmSync(databasePath, { recursive: true, force: true })); + +async function fixture(inverseEditable = true) { + const prefix = `relations-${++nextId}`; + const db = dbModule.getDb(); + const { schema } = dbModule; + const sourceDatabaseId = `${prefix}-deliverables`; + const targetDatabaseId = `${prefix}-team`; + const sourcePageId = `${prefix}-launch`; + const targetPageId = `${prefix}-mira`; + await db.insert(schema.documents).values([ + { + id: `${sourceDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Campaign deliverables", + }, + { + id: `${targetDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Marketing team", + }, + { id: sourcePageId, spaceId, ownerEmail: owner, title: "Launch article" }, + { id: targetPageId, spaceId, ownerEmail: owner, title: "Mira" }, + ]); + await db.insert(schema.contentDatabases).values([ + { + id: sourceDatabaseId, + spaceId, + ownerEmail: owner, + documentId: `${sourceDatabaseId}-page`, + title: "Deliverables", + blocksSeeded: 1, + }, + { + id: targetDatabaseId, + spaceId, + ownerEmail: owner, + documentId: `${targetDatabaseId}-page`, + title: "Team", + blocksSeeded: 1, + }, + ]); + await db.insert(schema.contentDatabaseItems).values([ + { + id: `${prefix}-source-item`, + databaseId: sourceDatabaseId, + documentId: sourcePageId, + ownerEmail: owner, + }, + { + id: `${prefix}-target-item`, + databaseId: targetDatabaseId, + documentId: targetPageId, + ownerEmail: owner, + }, + ]); + const configured = await configure.run({ + ownerDatabaseId: sourceDatabaseId, + alias: "Contributors", + operationId: `${prefix}-configure`, + definition: { + kind: "new-local", + forwardLabel: "Contributes to", + inverseLabel: "Deliverables", + forwardCardinality: "many", + sourceDatabaseId, + targetDatabaseId, + }, + inverseProjection: { + ownerDatabaseId: targetDatabaseId, + alias: "Deliverables", + editable: inverseEditable, + }, + }); + const propertyId = configured.projection.propertyId; + await mutate.run({ + operationId: `${prefix}-add`, + changes: [ + { + kind: "add", + typeId: configured.relationshipType.id, + typeVersionId: configured.relationshipTypeVersion.id, + sourcePageId, + targetPageId, + route: { kind: "forward-property", propertyId, sourcePageId }, + }, + ], + }); + return { + prefix, + sourceDatabaseId, + targetDatabaseId, + sourcePageId, + targetPageId, + propertyId, + configured, + }; +} + +describe("canonical relationship integration", () => { + it("exposes inverse edit policy and preserves it through metadata updates", () => + asOwner(async () => { + const f = await fixture(false); + const inverseId = f.configured.inverseProjection!.propertyId; + const read = async () => { + const [page] = await dbModule + .getDb() + .select() + .from(dbModule.schema.documents) + .where(eq(dbModule.schema.documents.id, f.targetPageId)); + const values = await listProperties(f.targetDatabaseId, [page]); + return values + .get(f.targetPageId)! + .find((property) => property.definition.id === inverseId)!; + }; + expect((await read()).editable).toBe(false); + const definition = { + kind: "existing" as const, + relationshipTypeId: f.configured.relationshipType.id, + direction: "inverse" as const, + }; + await configure.run({ + ownerDatabaseId: f.targetDatabaseId, + propertyId: inverseId, + alias: "My deliverables", + description: "Assignment history", + editable: true, + definition, + operationId: `${f.prefix}-enable-inverse`, + }); + expect((await read()).editable).toBe(true); + await configure.run({ + ownerDatabaseId: f.targetDatabaseId, + propertyId: inverseId, + alias: "Creative work", + visibility: "hide_when_empty", + definition, + operationId: `${f.prefix}-rename-inverse`, + }); + const after = await read(); + expect(after.editable).toBe(true); + expect(after.definition).toMatchObject({ + id: inverseId, + name: "Creative work", + description: "Assignment history", + visibility: "hide_when_empty", + }); + expect(after.value).toEqual([f.sourcePageId]); + })); + + it("fails closed when a persisted projection loses its property marker", () => + asOwner(async () => { + const f = await fixture(); + await dbModule + .getDb() + .update(dbModule.schema.documentPropertyDefinitions) + .set({ optionsJson: "{}" }) + .where( + eq(dbModule.schema.documentPropertyDefinitions.id, f.propertyId), + ); + const context = { + documentId: f.sourcePageId, + databaseId: f.sourceDatabaseId, + propertyId: f.propertyId, + }; + await expect(deleteProperty.run(context)).rejects.toMatchObject({ + errorCode: "UNAVAILABLE", + }); + await expect(duplicateProperty.run(context)).rejects.toMatchObject({ + errorCode: "UNAVAILABLE", + }); + await expect( + configureProperty.run({ + documentId: f.sourcePageId, + databaseId: f.sourceDatabaseId, + id: f.propertyId, + name: "Changed", + type: "text", + }), + ).rejects.toMatchObject({ errorCode: "UNAVAILABLE" }); + })); + + it("allows duplication of unassigned rows and rejects silent loss of existing relationships", () => + asOwner(async () => { + const f = await fixture(); + const { getDb, schema } = dbModule; + const pageId = `${f.prefix}-unassigned`; + const itemId = `${f.prefix}-unassigned-item`; + await getDb().insert(schema.documents).values({ + id: pageId, + ownerEmail: owner, + spaceId, + title: "Unassigned draft", + }); + await getDb().insert(schema.contentDatabaseItems).values({ + id: itemId, + databaseId: f.sourceDatabaseId, + documentId: pageId, + ownerEmail: owner, + }); + const duplicate = (await import("./duplicate-database-item.js")).default; + const result = await duplicate.run({ itemId }); + expect(result.duplicatedDocumentId).not.toBe(pageId); + expect( + (await list.run({ pageId: result.duplicatedDocumentId })).items, + ).toEqual([]); + await expect( + duplicate.run({ itemId: `${f.prefix}-source-item` }), + ).rejects.toMatchObject({ errorCode: "UNSUPPORTED_CONFIGURATION" }); + })); + + it("reports an unreadable legacy relation instead of an empty assignment", () => + asOwner(async () => { + const f = await fixture(); + const { getDb, schema } = dbModule; + const legacyId = `${f.prefix}-legacy`; + await getDb() + .insert(schema.documentPropertyDefinitions) + .values({ + id: legacyId, + databaseId: f.sourceDatabaseId, + ownerEmail: owner, + name: "Legacy relation", + type: "relation", + optionsJson: JSON.stringify({ + relation: { databaseId: f.targetDatabaseId }, + }), + }); + await getDb() + .insert(schema.documentPropertyValues) + .values({ + id: `${f.prefix}-legacy-value`, + documentId: f.sourcePageId, + propertyId: legacyId, + ownerEmail: owner, + valueJson: JSON.stringify({ unsupportedProviderPayload: true }), + }); + const [page] = await getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, f.sourcePageId)); + await expect( + listProperties(f.sourceDatabaseId, [page]), + ).rejects.toMatchObject({ errorCode: "UNSUPPORTED_CONFIGURATION" }); + })); + + it("suspends and restores the same lineage through the Page lifecycle Actions", () => + asOwner(async () => { + const f = await fixture(); + const trash = (await import("./delete-document.js")).default; + const restore = (await import("./restore-document.js")).default; + const remove = (await import("./permanently-delete-document.js")).default; + const initial = (await list.run({ pageId: f.sourcePageId })).items[0]; + const { schema, getDb } = dbModule; + const [page] = await getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, f.sourcePageId)); + await trash.run({ id: f.targetPageId }); + const suspended = (await listProperties(f.sourceDatabaseId, [page])).get( + page.id, + )!; + expect( + suspended.find((p) => p.definition.id === f.propertyId)?.value, + ).toEqual([]); + await restore.run({ id: f.targetPageId }); + expect( + (await list.run({ pageId: f.sourcePageId })).items.map( + (edge) => edge.edgeId, + ), + ).toContain(initial.edgeId); + await trash.run({ id: f.targetPageId }); + await remove.run({ id: f.targetPageId }); + await expect(restore.run({ id: f.targetPageId })).rejects.toThrow(); + const deleted = (await listProperties(f.sourceDatabaseId, [page])).get( + page.id, + )!; + expect( + deleted.find((p) => p.definition.id === f.propertyId)?.value, + ).toEqual([]); + })); + + it("prevents legacy property Actions from replacing or deleting canonical truth", () => + asOwner(async () => { + const f = await fixture(); + const context = { + documentId: f.sourcePageId, + databaseId: f.sourceDatabaseId, + propertyId: f.propertyId, + }; + await expect( + setProperty.run({ ...context, value: [] }), + ).rejects.toMatchObject({ errorCode: "USE_RELATIONSHIP_MUTATION" }); + const bulk = (await import("./update-database-items.js")).default; + await expect( + bulk.run({ + databaseId: f.sourceDatabaseId, + documentIds: [f.sourcePageId], + propertyId: f.propertyId, + value: [], + }), + ).rejects.toMatchObject({ errorCode: "USE_RELATIONSHIP_MUTATION" }); + await expect(deleteProperty.run(context)).rejects.toMatchObject({ + errorCode: "USE_RELATIONSHIP_MUTATION", + }); + await expect(duplicateProperty.run(context)).rejects.toMatchObject({ + errorCode: "USE_RELATIONSHIP_MUTATION", + }); + await expect( + configureProperty.run({ + documentId: f.sourcePageId, + databaseId: f.sourceDatabaseId, + id: f.propertyId, + name: "Text", + type: "text", + }), + ).rejects.toMatchObject({ errorCode: "USE_RELATIONSHIP_MUTATION" }); + expect((await list.run({ pageId: f.sourcePageId })).items).toHaveLength( + 1, + ); + })); + + it("hydrates canonical relation values and rollup count without stored endpoint arrays", () => + asOwner(async () => { + const f = await fixture(); + const { schema, getDb } = dbModule; + const rollupId = `${f.prefix}-count`; + await getDb() + .insert(schema.documentPropertyDefinitions) + .values({ + id: rollupId, + databaseId: f.sourceDatabaseId, + ownerEmail: owner, + name: "Contributor count", + type: "rollup", + optionsJson: JSON.stringify({ + rollup: { relationPropertyId: f.propertyId, aggregation: "count" }, + }), + }); + const [page] = await getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, f.sourcePageId)); + const properties = (await listProperties(f.sourceDatabaseId, [page])).get( + page.id, + )!; + expect( + properties.find((p) => p.definition.id === f.propertyId)?.value, + ).toEqual([f.targetPageId]); + expect(properties.find((p) => p.definition.id === rollupId)?.value).toBe( + 1, + ); + const raw = await getDb() + .select() + .from(schema.documentPropertyValues) + .where(eq(schema.documentPropertyValues.propertyId, f.propertyId)); + expect(raw).toHaveLength(0); + const { buildCollectionExportProjection } = + await import("./_collection-export.js"); + const exported = await buildCollectionExportProjection( + `${f.sourceDatabaseId}-page`, + { + scope: { kind: "all_members" }, + propertyIds: [f.propertyId, rollupId], + includePrimaryBody: false, + blockPropertyIds: [], + }, + ); + expect(exported.records[0].scalarValues.get(f.propertyId)).toContain( + f.targetPageId, + ); + expect(exported.records[0].scalarValues.get(rollupId)).toBe("1"); + })); + + it("does not expose a private target or its count through ordinary property reads", () => + asOwner(async () => { + const f = await fixture(); + const { schema, getDb } = dbModule; + await getDb() + .insert(schema.documentShares) + .values([ + { + id: `${f.prefix}-source-share`, + resourceId: f.sourcePageId, + principalType: "user", + principalId: viewer, + role: "viewer", + createdBy: owner, + }, + { + id: `${f.prefix}-database-share`, + resourceId: `${f.sourceDatabaseId}-page`, + principalType: "user", + principalId: viewer, + role: "viewer", + createdBy: owner, + }, + ]); + const [page] = await getDb() + .select() + .from(schema.documents) + .where(eq(schema.documents.id, f.sourcePageId)); + await runWithRequestContext({ userEmail: viewer }, async () => { + const properties = ( + await listProperties(f.sourceDatabaseId, [page]) + ).get(page.id)!; + expect( + properties.find((p) => p.definition.id === f.propertyId)?.value, + ).toEqual([]); + expect((await list.run({ pageId: f.sourcePageId })).items).toEqual([]); + const { buildCollectionExportProjection } = + await import("./_collection-export.js"); + const exported = await buildCollectionExportProjection( + `${f.sourceDatabaseId}-page`, + { + scope: { kind: "all_members" }, + propertyIds: [f.propertyId], + includePrimaryBody: false, + blockPropertyIds: [], + }, + ); + expect(exported.records[0].scalarValues.get(f.propertyId)).toBe(""); + }); + })); +}); diff --git a/templates/content/actions/configure-content-relation-property.ts b/templates/content/actions/configure-content-relation-property.ts new file mode 100644 index 00000000000..7a65c358a15 --- /dev/null +++ b/templates/content/actions/configure-content-relation-property.ts @@ -0,0 +1,536 @@ +import { defineAction, type ActionRunContext } from "@agent-native/core/action"; +import { and, eq, isNull, sql } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { + configureContentRelationPropertyInputSchema, + type CanonicalRelationProjection, + type ConfigureContentRelationPropertyInput, + type ConfigureContentRelationPropertyResult, +} from "../shared/relationships.js"; +import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; +import { nanoid } from "./_property-utils.js"; +import { + appendRelationshipEvent, + assertSameRelationshipTenant, + createRelationshipRevision, + insertRelationshipReceipt, + loadRelationshipDatabase, + loadRelationshipTypeBundle, + lockRelationshipOperation, + lockRelationshipTypes, + relationshipCapabilities, + relationshipActorContext, + relationshipError, + relationshipProjectionDto, + relationshipRequestHash, + relationshipTenant, + relationshipTypeDto, + relationshipTypeVersionDto, + replayRelationshipReceipt, + type RelationshipDb, +} from "./_relationship-core.js"; + +async function nextPropertyPosition( + db: RelationshipDb, + databaseId: string, +): Promise { + const [row] = await db + .select({ + value: sql`coalesce(max(${schema.documentPropertyDefinitions.position}), -1)`, + }) + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.databaseId, databaseId)); + return Number(row?.value ?? -1) + 1; +} + +function projectionOptions(args: { + oppositeDatabaseId: string; + relationshipTypeId: string; + direction: "forward" | "inverse"; + editable: boolean; +}): string { + return JSON.stringify({ + relation: { + databaseId: args.oppositeDatabaseId, + relationshipTypeId: args.relationshipTypeId, + direction: args.direction, + editable: args.editable, + }, + }); +} + +async function persistProjection( + tx: RelationshipDb, + args: { + propertyId?: string; + database: typeof schema.contentDatabases.$inferSelect; + typeId: string; + direction: "forward" | "inverse"; + oppositeDatabaseId: string; + alias: string; + description?: string; + editable: boolean; + visibility?: "always_show" | "hide_when_empty" | "always_hide"; + actorEmail: string; + }, +): Promise { + const propertyId = args.propertyId ?? nanoid(18); + const [existingProjection] = await tx + .select() + .from(schema.contentRelationshipProjections) + .where(eq(schema.contentRelationshipProjections.propertyId, propertyId)); + const [existingDefinition] = await tx + .select() + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.id, propertyId)); + if (existingProjection) { + if ( + existingProjection.databaseId !== args.database.id || + existingProjection.relationshipTypeId !== args.typeId || + existingProjection.direction !== args.direction || + !existingDefinition + ) { + relationshipError( + "INVALID_TARGET", + "The requested relation Property ID belongs to a different projection.", + { statusCode: 409 }, + ); + } + const now = new Date().toISOString(); + const description = args.description ?? existingProjection.description; + await tx + .update(schema.contentRelationshipProjections) + .set({ + alias: args.alias, + description, + editable: args.editable ? 1 : 0, + archivedAt: null, + updatedAt: now, + }) + .where( + eq(schema.contentRelationshipProjections.id, existingProjection.id), + ); + await tx + .update(schema.documentPropertyDefinitions) + .set({ + name: args.alias, + description, + type: "relation", + optionsJson: projectionOptions({ + oppositeDatabaseId: args.oppositeDatabaseId, + relationshipTypeId: args.typeId, + direction: args.direction, + editable: args.editable, + }), + ...(args.visibility ? { visibility: args.visibility } : {}), + updatedAt: now, + }) + .where(eq(schema.documentPropertyDefinitions.id, propertyId)); + return { + ...existingProjection, + alias: args.alias, + description, + editable: args.editable ? 1 : 0, + archivedAt: null, + updatedAt: now, + }; + } + if (existingDefinition) { + relationshipError( + "INVALID_TARGET", + "The requested Property ID already belongs to another property.", + { statusCode: 409 }, + ); + } + const projectionId = nanoid(18); + const position = await nextPropertyPosition(tx, args.database.id); + await tx.insert(schema.documentPropertyDefinitions).values({ + id: propertyId, + ownerEmail: args.database.ownerEmail, + orgId: args.database.orgId, + databaseId: args.database.id, + name: args.alias, + type: "relation", + description: args.description ?? "", + ...(args.visibility ? { visibility: args.visibility } : {}), + optionsJson: projectionOptions({ + oppositeDatabaseId: args.oppositeDatabaseId, + relationshipTypeId: args.typeId, + direction: args.direction, + editable: args.editable, + }), + position, + }); + await tx.insert(schema.contentRelationshipProjections).values({ + id: projectionId, + ownerEmail: args.database.ownerEmail, + orgId: args.database.orgId, + spaceId: args.database.spaceId!, + propertyId, + databaseId: args.database.id, + relationshipTypeId: args.typeId, + direction: args.direction, + editable: args.editable ? 1 : 0, + alias: args.alias, + description: args.description ?? "", + createdBy: args.actorEmail, + }); + const [created] = await tx + .select() + .from(schema.contentRelationshipProjections) + .where(eq(schema.contentRelationshipProjections.id, projectionId)); + if (!created) { + relationshipError( + "UNAVAILABLE", + "The relation projection was not committed.", + { + statusCode: 503, + }, + ); + } + return created; +} + +async function configureContentRelationProperty( + args: ConfigureContentRelationPropertyInput, + context?: ActionRunContext, +): Promise { + const ownerContext = await loadRelationshipDatabase( + args.ownerDatabaseId, + "admin", + getDb(), + context, + ); + let sourceContext; + let targetContext; + if (args.definition.kind === "new-local") { + if (args.ownerDatabaseId !== args.definition.sourceDatabaseId) { + relationshipError( + "INVALID_TARGET", + "A new forward relation Property must be owned by its source database.", + ); + } + sourceContext = ownerContext; + targetContext = await loadRelationshipDatabase( + args.definition.targetDatabaseId, + args.inverseProjection ? "admin" : "viewer", + getDb(), + context, + ); + assertSameRelationshipTenant( + sourceContext.database, + targetContext.database, + ); + } else { + const bundle = await loadRelationshipTypeBundle( + args.definition.relationshipTypeId, + ); + const expectedOwnerDatabaseId = + args.definition.direction === "forward" + ? bundle.version.sourceDatabaseId + : bundle.version.targetDatabaseId; + if (ownerContext.database.id !== expectedOwnerDatabaseId) { + relationshipError( + "INVALID_TARGET", + "The Property owner does not match the selected relationship direction.", + ); + } + sourceContext = + args.definition.direction === "forward" + ? ownerContext + : await loadRelationshipDatabase( + bundle.version.sourceDatabaseId, + "viewer", + getDb(), + context, + ); + targetContext = + args.definition.direction === "inverse" + ? ownerContext + : await loadRelationshipDatabase( + bundle.version.targetDatabaseId, + args.inverseProjection ? "admin" : "viewer", + getDb(), + context, + ); + assertSameRelationshipTenant( + sourceContext.database, + targetContext.database, + ); + } + if (sourceContext.database.systemRole || targetContext.database.systemRole) { + relationshipError( + "UNSUPPORTED_CONFIGURATION", + "Typed relationships currently support ordinary Content databases only.", + ); + } + if ( + args.inverseProjection && + args.inverseProjection.ownerDatabaseId !== targetContext.database.id + ) { + relationshipError( + "INVALID_TARGET", + "The inverse projection must be owned by the target database.", + ); + } + + const requestHash = relationshipRequestHash(args); + const actor = relationshipActorContext(context); + const db = getDb(); + return db.transaction(async (rawTx) => { + const tx = rawTx as unknown as RelationshipDb; + await lockRelationshipOperation(tx, { + tenant: relationshipTenant(sourceContext.database), + operationId: args.operationId, + context, + }); + const lockedDatabaseIds = [ + ...new Set([sourceContext.database.id, targetContext.database.id]), + ].sort(); + for (const databaseId of lockedDatabaseIds) { + await lockContentDatabaseMutation(tx, databaseId); + } + await loadRelationshipDatabase(args.ownerDatabaseId, "admin", tx, context); + await loadRelationshipDatabase( + sourceContext.database.id, + "viewer", + tx, + context, + ); + await loadRelationshipDatabase( + targetContext.database.id, + args.inverseProjection ? "admin" : "viewer", + tx, + context, + ); + const replayed = + await replayRelationshipReceipt( + tx, + { + spaceId: sourceContext.database.spaceId!, + operationId: args.operationId, + requestHash, + context, + }, + ); + if (replayed) return replayed; + + const [lockedSource] = await tx + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.id, sourceContext.database.id), + isNull(schema.contentDatabases.deletedAt), + ), + ); + const [lockedTarget] = await tx + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.id, targetContext.database.id), + isNull(schema.contentDatabases.deletedAt), + ), + ); + if (!lockedSource || !lockedTarget) { + relationshipError( + "CONSTRAINT_UNAVAILABLE", + "A relationship database became unavailable.", + { statusCode: 409 }, + ); + } + assertSameRelationshipTenant(lockedSource, lockedTarget); + + let typeRow: typeof schema.contentRelationshipTypes.$inferSelect; + let versionRow: typeof schema.contentRelationshipTypeVersions.$inferSelect; + if (args.definition.kind === "new-local") { + const typeId = nanoid(18); + const versionId = nanoid(18); + await tx.insert(schema.contentRelationshipTypes).values({ + id: typeId, + ownerEmail: lockedSource.ownerEmail, + orgId: lockedSource.orgId, + spaceId: lockedSource.spaceId!, + currentVersionId: versionId, + createdBy: actor.actor.displayName, + }); + await tx.insert(schema.contentRelationshipTypeVersions).values({ + id: versionId, + ownerEmail: lockedSource.ownerEmail, + orgId: lockedSource.orgId, + spaceId: lockedSource.spaceId!, + relationshipTypeId: typeId, + version: 1, + forwardLabel: args.definition.forwardLabel, + inverseLabel: args.definition.inverseLabel, + forwardCardinality: args.definition.forwardCardinality, + sourceDatabaseId: lockedSource.id, + targetDatabaseId: lockedTarget.id, + createdBy: actor.actor.displayName, + }); + [typeRow] = await tx + .select() + .from(schema.contentRelationshipTypes) + .where(eq(schema.contentRelationshipTypes.id, typeId)); + [versionRow] = await tx + .select() + .from(schema.contentRelationshipTypeVersions) + .where(eq(schema.contentRelationshipTypeVersions.id, versionId)); + } else { + await lockRelationshipTypes(tx, [args.definition.relationshipTypeId]); + const bundle = await loadRelationshipTypeBundle( + args.definition.relationshipTypeId, + { db: tx }, + ); + typeRow = bundle.type; + versionRow = bundle.version; + } + if (!typeRow! || !versionRow!) { + relationshipError( + "UNAVAILABLE", + "The relationship type was not committed.", + { + statusCode: 503, + }, + ); + } + const direction = + args.definition.kind === "new-local" + ? "forward" + : args.definition.direction; + const ownerDatabase = direction === "forward" ? lockedSource : lockedTarget; + const oppositeDatabase = + direction === "forward" ? lockedTarget : lockedSource; + const [existingPrimaryProjection] = args.propertyId + ? await tx + .select({ editable: schema.contentRelationshipProjections.editable }) + .from(schema.contentRelationshipProjections) + .where( + eq( + schema.contentRelationshipProjections.propertyId, + args.propertyId, + ), + ) + : []; + if (direction === "forward" && args.editable === false) { + relationshipError( + "UNSUPPORTED_CONFIGURATION", + "Forward relation Properties are editable in this relationship slice.", + ); + } + const primaryRow = await persistProjection(tx, { + propertyId: args.propertyId, + database: ownerDatabase, + typeId: typeRow.id, + direction, + oppositeDatabaseId: oppositeDatabase.id, + alias: args.alias, + description: args.description, + editable: + direction === "forward" + ? true + : (args.editable ?? existingPrimaryProjection?.editable === 1), + visibility: args.visibility, + actorEmail: actor.actor.displayName, + }); + let inverseRow: + | typeof schema.contentRelationshipProjections.$inferSelect + | undefined; + if (args.inverseProjection) { + inverseRow = await persistProjection(tx, { + propertyId: args.inverseProjection.propertyId, + database: lockedTarget, + typeId: typeRow.id, + direction: "inverse", + oppositeDatabaseId: lockedSource.id, + alias: args.inverseProjection.alias, + description: args.inverseProjection.description, + editable: args.inverseProjection.editable, + visibility: args.inverseProjection.visibility, + actorEmail: actor.actor.displayName, + }); + } + const tenant = relationshipTenant(lockedSource); + const projectionDtos: CanonicalRelationProjection[] = [ + relationshipProjectionDto(primaryRow), + ...(inverseRow ? [relationshipProjectionDto(inverseRow)] : []), + ]; + const revision = await createRelationshipRevision(tx, { + tenant, + operationId: args.operationId, + operation: "configure-relation-property", + diff: { + relationshipTypeId: typeRow.id, + relationshipTypeVersionId: versionRow.id, + projections: projectionDtos, + }, + context, + }); + await appendRelationshipEvent(tx, revision, { + tenant, + kind: "relationship-projection-configured", + relationshipTypeId: typeRow.id, + relationshipTypeVersionId: versionRow.id, + targets: { + propertyIds: projectionDtos.map((projection) => projection.propertyId), + databaseIds: projectionDtos.map((projection) => projection.databaseId), + }, + diff: { projections: projectionDtos }, + }); + const receiptId = nanoid(24); + const schemaRevision = relationshipRequestHash({ + typeId: typeRow.id, + versionId: versionRow.id, + projections: projectionDtos, + revisionId: revision.revisionId, + }); + const result: ConfigureContentRelationPropertyResult = { + operationId: args.operationId, + receiptId, + revisionId: revision.revisionId, + eventIds: revision.eventIds, + relationshipType: relationshipTypeDto(typeRow), + relationshipTypeVersion: relationshipTypeVersionDto(versionRow), + projection: relationshipProjectionDto(primaryRow), + ...(inverseRow + ? { inverseProjection: relationshipProjectionDto(inverseRow) } + : {}), + capabilities: relationshipCapabilities({ + databaseRole: ownerContext.role, + pageRole: ownerContext.role, + direction, + editable: primaryRow.editable === 1, + cardinality: versionRow.forwardCardinality === "one" ? "one" : "many", + }), + schemaRevision, + invalidation: { + pageIds: [lockedSource.documentId, lockedTarget.documentId], + databaseIds: [lockedSource.id, lockedTarget.id].sort(), + propertyIds: projectionDtos + .map((projection) => projection.propertyId) + .sort(), + relationshipTypeIds: [typeRow.id], + }, + }; + await insertRelationshipReceipt(tx, { + id: receiptId, + tenant, + operationId: args.operationId, + requestHash, + revisionId: revision.revisionId, + result, + context, + }); + return result; + }); +} + +export default defineAction({ + description: + "Create or update one canonical typed Content relation Property and an optional inverse projection.", + mcpTool: true, + schema: configureContentRelationPropertyInputSchema, + run: configureContentRelationProperty, +}); diff --git a/templates/content/actions/configure-document-property.ts b/templates/content/actions/configure-document-property.ts index c504aef31be..ea1ea8ca80f 100644 --- a/templates/content/actions/configure-document-property.ts +++ b/templates/content/actions/configure-document-property.ts @@ -1,4 +1,4 @@ -import { defineAction } from "@agent-native/core/action"; +import { ActionContractError, defineAction } from "@agent-native/core/action"; import { writeAppState } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; import { and, eq, sql } from "drizzle-orm"; @@ -17,6 +17,7 @@ import { type DocumentPropertyType, } from "../shared/properties.js"; import { deleteBlocksFieldIdentity } from "./_blocks-field-identity.js"; +import { assertNotCanonicalRelationDefinition } from "./_canonical-relation-guard.js"; import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; import { lockDatabaseMemberships } from "./_database-membership-lock.js"; import { @@ -111,6 +112,14 @@ export default defineAction({ const now = new Date().toISOString(); const name = args.name.trim(); const type = args.type as DocumentPropertyType; + if (type === "relation") { + throw new ActionContractError( + "Configure relations through configure-content-relation-property.", + { + errorCode: "USE_RELATIONSHIP_MUTATION", + }, + ); + } const propertyId = args.id ?? nanoid(); const optionsJson = optionsForNewProperty(type, args.options as any); const database = await resolvePropertyDatabaseForDocument( @@ -147,6 +156,7 @@ export default defineAction({ ), ); if (!existing) throw new Error(`Property "${args.id}" not found`); + await assertNotCanonicalRelationDefinition(db, existing); await db.transaction(async (tx) => { await lockContentDatabaseMutation( tx as unknown as ReturnType, @@ -172,6 +182,10 @@ export default defineAction({ ); if (!lockedDefinition) throw new Error(`Property "${args.id}" not found`); + await assertNotCanonicalRelationDefinition( + tx as unknown as ReturnType, + lockedDefinition, + ); if ( lockedDatabase.naturalKeyPropertyId === args.id && type !== "text" diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index 297e6a4edd6..eeb3b2fe9f2 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -81,12 +81,12 @@ beforeAll(async () => { const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); await getDbExec().execute(`CREATE TABLE IF NOT EXISTS organizations ( - id TEXT PRIMARY KEY, name TEXT NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL, + id TEXT PRIMARY KEY, name TEXT NOT NULL, created_by TEXT NOT NULL, created_at BIGINT NOT NULL, identity_authority TEXT, identity_id TEXT )`); await getDbExec().execute(`CREATE TABLE IF NOT EXISTS org_members ( - id TEXT PRIMARY KEY, org_id TEXT NOT NULL, email TEXT NOT NULL, role TEXT NOT NULL, joined_at INTEGER NOT NULL, - federation_removal_pending_at INTEGER + id TEXT PRIMARY KEY, org_id TEXT NOT NULL, email TEXT NOT NULL, role TEXT NOT NULL, joined_at BIGINT NOT NULL, + federation_removal_pending_at BIGINT )`); }, 60000); diff --git a/templates/content/actions/delete-content-database.ts b/templates/content/actions/delete-content-database.ts index a9d644e3311..4636414d7b9 100644 --- a/templates/content/actions/delete-content-database.ts +++ b/templates/content/actions/delete-content-database.ts @@ -16,7 +16,7 @@ export default defineAction({ schema: z.object({ databaseId: z.string().describe("Content database ID"), }), - run: async ({ databaseId }) => { + run: async ({ databaseId }, context) => { const { database } = await assertContentDatabaseLifecycleAccess(databaseId); if (database.systemRole) { throw new Error("System Content databases cannot be deleted"); @@ -37,6 +37,7 @@ export default defineAction({ database.ownerEmail, deletedAt, lockedDatabaseIds, + context, ); }); diff --git a/templates/content/actions/delete-content-space.ts b/templates/content/actions/delete-content-space.ts index 2d04048ed11..8ea58207662 100644 --- a/templates/content/actions/delete-content-space.ts +++ b/templates/content/actions/delete-content-space.ts @@ -11,8 +11,8 @@ export default defineAction({ schema: z.object({ spaceId: z.string().trim().min(1), }), - run: async ({ spaceId }) => { - const result = await deleteUserContentSpace(getDb(), spaceId); + run: async ({ spaceId }, context) => { + const result = await deleteUserContentSpace(getDb(), spaceId, context); await writeAppState("refresh-signal", { ts: Date.now() }); return { success: true, ...result }; }, diff --git a/templates/content/actions/delete-document-property.ts b/templates/content/actions/delete-document-property.ts index 9c94ae56e9d..cff1d9bae11 100644 --- a/templates/content/actions/delete-document-property.ts +++ b/templates/content/actions/delete-document-property.ts @@ -17,6 +17,7 @@ import { deleteBlocksFieldIdentity, lockPrimaryBlocksFieldsForDocuments, } from "./_blocks-field-identity.js"; +import { assertNotCanonicalRelationDefinition } from "./_canonical-relation-guard.js"; import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; import { lockDatabaseMemberships } from "./_database-membership-lock.js"; import { @@ -64,6 +65,7 @@ export default defineAction({ ), ); if (!definition) throw new Error(`Property "${propertyId}" not found`); + await assertNotCanonicalRelationDefinition(db, definition); if (definition.systemRole) { throw new Error("System properties cannot be deleted."); } @@ -97,6 +99,10 @@ export default defineAction({ ); if (!lockedDefinition) throw new Error(`Property "${propertyId}" not found`); + await assertNotCanonicalRelationDefinition( + tx as unknown as ReturnType, + lockedDefinition, + ); if (lockedDefinition.systemRole) { throw new Error("System properties cannot be deleted."); } diff --git a/templates/content/actions/delete-document.test.ts b/templates/content/actions/delete-document.test.ts index fcc27def212..46f82520cc8 100644 --- a/templates/content/actions/delete-document.test.ts +++ b/templates/content/actions/delete-document.test.ts @@ -119,6 +119,15 @@ const { schema } = vi.hoisted(() => ({ }, })); +vi.mock("./_relationship-lifecycle.js", () => ({ + applyRelationshipDocumentLifecycleInsideTransaction: vi.fn(async () => ({ + revisionId: null, + eventIds: [], + incidentTypeIds: [], + incidentPageIds: [], + })), +})); + vi.mock("../server/db/index.js", () => ({ getDb: vi.fn(), schema, diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index 91265724e2a..526ba18802f 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -1,4 +1,6 @@ -import { defineAction } from "@agent-native/core/action"; +import { randomUUID } from "node:crypto"; + +import { defineAction, type ActionRunContext } from "@agent-native/core/action"; import { writeAppState } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; import { and, eq, inArray, isNotNull, isNull, ne, or } from "drizzle-orm"; @@ -14,6 +16,7 @@ import { import { assertNotWorkspaceCatalogDocuments } from "./_content-space-catalog-guards.js"; import { lockDatabaseMemberships } from "./_database-membership-lock.js"; import { renumberDatabaseRows } from "./_database-row-batch.js"; +import { applyRelationshipDocumentLifecycleInsideTransaction } from "./_relationship-lifecycle.js"; const DELETE_BATCH_SIZE = 90; @@ -340,6 +343,7 @@ export async function trashDocumentSubtree( ownerEmail: string, trashedAt = new Date().toISOString(), lockedDatabaseIds?: ReadonlySet, + context?: ActionRunContext, ): Promise { const { documentIds, ownedDatabaseIds } = await collectDocumentSubtreeForDelete(db, id, ownerEmail); @@ -429,6 +433,13 @@ export async function trashDocumentSubtree( await touchContentDatabase(db, databaseId, trashedAt); } + await applyRelationshipDocumentLifecycleInsideTransaction(db, { + documentIds: activeDocumentIds, + operation: "trash", + operationId: randomUUID(), + context, + }); + for (const batch of chunks(activeDocumentIds, DELETE_BATCH_SIZE)) { await db .update(schema.documents) @@ -459,6 +470,7 @@ export async function restoreDocumentSubtree( db: ReturnType, rootId: string, ownerEmail: string, + context?: ActionRunContext, ): Promise { const collectRestoreScope = async () => { const documentIds = ( @@ -510,6 +522,12 @@ export async function restoreDocumentSubtree( const documentIds = restoreScope.documentIds; if (documentIds.length === 0) return []; const now = new Date().toISOString(); + await applyRelationshipDocumentLifecycleInsideTransaction(db, { + documentIds, + operation: "restore", + operationId: randomUUID(), + context, + }); for (const batch of chunks(documentIds, DELETE_BATCH_SIZE)) { await db .update(schema.documents) @@ -572,12 +590,14 @@ export async function deleteDocumentRecursive( db: ReturnType, id: string, ownerEmail: string, + context?: ActionRunContext, ): Promise { return db.transaction((tx) => deleteDocumentRootsRecursive( tx as unknown as ReturnType, [id], ownerEmail, + context, ), ); } @@ -586,6 +606,7 @@ export async function deleteDocumentRootsRecursive( db: ReturnType, rootIds: string[], ownerEmail: string, + context?: ActionRunContext, ): Promise { const collectScope = async () => { const documentIds = new Set(); @@ -615,6 +636,7 @@ export async function deleteDocumentRootsRecursive( documentIds, ownedDatabaseIds, ownerEmail, + context, ); } @@ -623,8 +645,15 @@ async function deleteCollectedDocuments( documentIds: string[], ownedDatabaseIds: string[], ownerEmail: string, + context?: ActionRunContext, ): Promise { await assertNotWorkspaceCatalogDocuments(db, documentIds, "deleted"); + await applyRelationshipDocumentLifecycleInsideTransaction(db, { + documentIds, + operation: "permanent-delete", + operationId: randomUUID(), + context, + }); const propertyDefinitionIds: string[] = []; await deleteWhereIn(ownedDatabaseIds, async (databaseIdBatch) => { @@ -845,6 +874,7 @@ export async function deleteTrashedDocumentSubtree( db: ReturnType, id: string, ownerEmail: string, + context?: ActionRunContext, ): Promise { const collectScope = async () => { const [root] = await db @@ -924,6 +954,7 @@ export async function deleteTrashedDocumentSubtree( documentIds, ownedDatabaseIds, ownerEmail, + context, ); } @@ -937,7 +968,7 @@ export default defineAction({ .optional() .describe("Database page the deletion was initiated from"), }), - run: async (args) => { + run: async (args, context) => { const id = args.id; if (!id) throw new Error("--id is required"); @@ -1006,6 +1037,7 @@ export default defineAction({ existing.ownerEmail as string, undefined, lockedDatabaseIds, + context, ); }); diff --git a/templates/content/actions/duplicate-database-item.ts b/templates/content/actions/duplicate-database-item.ts index 8a28fae9367..74ae74d5df2 100644 --- a/templates/content/actions/duplicate-database-item.ts +++ b/templates/content/actions/duplicate-database-item.ts @@ -6,6 +6,7 @@ import { and, eq, gte, isNull, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { assertRowsHaveNoCanonicalRelationships } from "./_canonical-relation-guard.js"; import { lockContentDatabaseMutation, touchContentDatabase, @@ -129,6 +130,10 @@ export default defineAction({ .where( eq(schema.documentPropertyValues.documentId, lockedRow.document.id), ); + await assertRowsHaveNoCanonicalRelationships( + tx as unknown as ReturnType, + [lockedRow.document.id], + ); const [claimedSource] = await tx .select({ id: schema.contentDatabaseItemKeyClaims.id }) .from(schema.contentDatabaseItemKeyClaims) diff --git a/templates/content/actions/duplicate-database-items.ts b/templates/content/actions/duplicate-database-items.ts index a92062ab08b..c7fa376ca80 100644 --- a/templates/content/actions/duplicate-database-items.ts +++ b/templates/content/actions/duplicate-database-items.ts @@ -5,6 +5,7 @@ import { assertAccess } from "@agent-native/core/sharing"; import { and, asc, eq, gte, inArray, isNull, sql } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; +import { assertRowsHaveNoCanonicalRelationships } from "./_canonical-relation-guard.js"; import { lockContentDatabaseMutation, touchContentDatabase, @@ -113,6 +114,10 @@ export default defineAction({ "Cannot duplicate database rows across Content spaces.", ); } + await assertRowsHaveNoCanonicalRelationships( + tx as unknown as ReturnType, + sourceDocumentIds, + ); const [claimedSource] = await tx .select({ id: schema.contentDatabaseItemKeyClaims.id }) .from(schema.contentDatabaseItemKeyClaims) diff --git a/templates/content/actions/duplicate-document-property.ts b/templates/content/actions/duplicate-document-property.ts index 66d1df20758..1818a89dd62 100644 --- a/templates/content/actions/duplicate-document-property.ts +++ b/templates/content/actions/duplicate-document-property.ts @@ -10,6 +10,7 @@ import { serializePropertyOptions, type DocumentPropertyType, } from "../shared/properties.js"; +import { assertNotCanonicalRelationDefinition } from "./_canonical-relation-guard.js"; import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; import { lockDatabaseMemberships } from "./_database-membership-lock.js"; import { @@ -61,6 +62,7 @@ export default defineAction({ ), ); if (!definition) throw new Error(`Property "${propertyId}" not found`); + await assertNotCanonicalRelationDefinition(db, definition); if (definition.systemRole) { throw new Error("System properties cannot be duplicated."); } @@ -99,6 +101,10 @@ export default defineAction({ if (!lockedDefinition) { throw new Error(`Property "${propertyId}" not found`); } + await assertNotCanonicalRelationDefinition( + tx as unknown as ReturnType, + lockedDefinition, + ); if (lockedDefinition.systemRole) { throw new Error("System properties cannot be duplicated."); } diff --git a/templates/content/actions/list-content-relation-candidates.ts b/templates/content/actions/list-content-relation-candidates.ts new file mode 100644 index 00000000000..869343432c0 --- /dev/null +++ b/templates/content/actions/list-content-relation-candidates.ts @@ -0,0 +1,332 @@ +import { defineAction, type ActionRunContext } from "@agent-native/core/action"; +import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { + listContentRelationCandidatesInputSchema, + type ContentRelationCandidate, + type ListContentRelationCandidatesInput, + type ListContentRelationCandidatesResult, +} from "../shared/relationships.js"; +import { resolveContentDocumentAccess } from "./_content-document-access.js"; +import { + activeActivationIdsForLineages, + decodeRelationshipCursor, + encodeRelationshipCursor, + loadRelationshipDatabase, + loadRelationshipTypeBundle, + relationshipError, +} from "./_relationship-core.js"; +import { issueRelationshipObservation } from "./_relationship-read.js"; + +async function slotObservation( + args: { + relationshipTypeId: string; + sourcePageId: string; + ownerEmail: string; + orgId: string | null; + spaceId: string; + }, + context?: ActionRunContext, +): Promise { + const db = getDb(); + const lineages = await db + .select({ id: schema.contentRelationshipLineages.id }) + .from(schema.contentRelationshipLineages) + .where( + and( + eq( + schema.contentRelationshipLineages.relationshipTypeId, + args.relationshipTypeId, + ), + eq(schema.contentRelationshipLineages.sourcePageId, args.sourcePageId), + ), + ); + const active = await activeActivationIdsForLineages( + db, + lineages.map((lineage) => lineage.id), + ); + return issueRelationshipObservation(db, { + kind: "slot", + relationshipTypeId: args.relationshipTypeId, + sourcePageId: args.sourcePageId, + activationIds: lineages.flatMap((lineage) => active.get(lineage.id) ?? []), + tenant: { + ownerEmail: args.ownerEmail, + orgId: args.orgId, + spaceId: args.spaceId, + }, + context, + }); +} + +async function listContentRelationCandidates( + input: ListContentRelationCandidatesInput, + context?: ActionRunContext, +): Promise { + const db = getDb(); + const [projection] = await db + .select() + .from(schema.contentRelationshipProjections) + .where( + and( + eq(schema.contentRelationshipProjections.propertyId, input.propertyId), + isNull(schema.contentRelationshipProjections.archivedAt), + ), + ); + if (!projection) { + relationshipError( + "NOT_ACCESSIBLE", + "The requested relation Property is not accessible.", + { statusCode: 404 }, + ); + } + const ownerDatabase = await loadRelationshipDatabase( + projection.databaseId, + "viewer", + db, + ); + const anchorAccess = await resolveContentDocumentAccess(input.anchorPageId); + if (!anchorAccess) { + relationshipError( + "NOT_ACCESSIBLE", + "The requested Content Page is not accessible.", + { statusCode: 404 }, + ); + } + const bundle = await loadRelationshipTypeBundle( + projection.relationshipTypeId, + { + db, + }, + ); + const anchorDatabaseId = + projection.direction === "forward" + ? bundle.version.sourceDatabaseId + : bundle.version.targetDatabaseId; + if (ownerDatabase.database.id !== anchorDatabaseId) { + relationshipError( + "UNSUPPORTED_CONFIGURATION", + "The relation Property does not match its canonical definition.", + ); + } + const [anchorMembership] = await db + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, anchorDatabaseId), + eq(schema.contentDatabaseItems.documentId, input.anchorPageId), + ), + ); + if (!anchorMembership) { + relationshipError( + "INVALID_TARGET", + "The anchor Page is outside the relation Property's database.", + { statusCode: 409 }, + ); + } + const candidateDatabaseId = + projection.direction === "forward" + ? bundle.version.targetDatabaseId + : bundle.version.sourceDatabaseId; + await loadRelationshipDatabase(candidateDatabaseId, "viewer", db); + const memberships = await db + .select({ documentId: schema.contentDatabaseItems.documentId }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, candidateDatabaseId)); + const permanentlyDeleted = memberships.length + ? await db + .select({ pageId: schema.contentRelationshipEndpointStates.pageId }) + .from(schema.contentRelationshipEndpointStates) + .where( + and( + inArray( + schema.contentRelationshipEndpointStates.pageId, + memberships.map((membership) => membership.documentId), + ), + isNotNull( + schema.contentRelationshipEndpointStates.permanentlyDeletedAt, + ), + ), + ) + : []; + const deletedIds = new Set(permanentlyDeleted.map((row) => row.pageId)); + const accessibleIds: string[] = []; + for (const membership of memberships) { + if ( + membership.documentId !== input.anchorPageId && + !deletedIds.has(membership.documentId) && + (await resolveContentDocumentAccess(membership.documentId)) + ) { + accessibleIds.push(membership.documentId); + } + } + if (accessibleIds.length === 0) { + return { + scope: "viewer-accessible", + items: [], + slotObservationToken: + projection.direction === "forward" && + bundle.version.forwardCardinality === "one" + ? await slotObservation( + { + relationshipTypeId: bundle.type.id, + sourcePageId: input.anchorPageId, + ownerEmail: bundle.type.ownerEmail, + orgId: bundle.type.orgId, + spaceId: bundle.type.spaceId, + }, + context, + ) + : null, + nextCursor: null, + }; + } + const documents = await db + .select({ + id: schema.documents.id, + title: schema.documents.title, + trashedAt: schema.documents.trashedAt, + }) + .from(schema.documents) + .where(inArray(schema.documents.id, accessibleIds)); + const search = input.search.toLocaleLowerCase(); + const filtered = documents + .filter( + (document) => + !document.trashedAt && + (!search || document.title.toLocaleLowerCase().includes(search)), + ) + .sort( + (left, right) => + left.title.localeCompare(right.title) || + left.id.localeCompare(right.id), + ); + const contextDefinitions = input.contextPropertyIds.length + ? await db + .select({ + id: schema.documentPropertyDefinitions.id, + type: schema.documentPropertyDefinitions.type, + }) + .from(schema.documentPropertyDefinitions) + .where( + and( + inArray( + schema.documentPropertyDefinitions.id, + input.contextPropertyIds, + ), + eq( + schema.documentPropertyDefinitions.databaseId, + candidateDatabaseId, + ), + ), + ) + : []; + if (contextDefinitions.length !== new Set(input.contextPropertyIds).size) { + relationshipError( + "INVALID_TARGET", + "A requested context Property is not part of the candidate database.", + ); + } + if (contextDefinitions.some((definition) => definition.type === "relation")) { + relationshipError( + "UNSUPPORTED_CONFIGURATION", + "Relation Properties cannot be returned as raw candidate context. Request ordinary context Properties instead.", + ); + } + const offset = decodeRelationshipCursor(input.cursor); + const page = filtered.slice(offset, offset + input.limit); + const valueRows = + page.length && contextDefinitions.length + ? await db + .select({ + documentId: schema.documentPropertyValues.documentId, + propertyId: schema.documentPropertyValues.propertyId, + valueJson: schema.documentPropertyValues.valueJson, + }) + .from(schema.documentPropertyValues) + .where( + and( + inArray( + schema.documentPropertyValues.documentId, + page.map((document) => document.id), + ), + inArray( + schema.documentPropertyValues.propertyId, + contextDefinitions.map((definition) => definition.id), + ), + ), + ) + : []; + const contextByPage = new Map>(); + for (const row of valueRows) { + let value: unknown; + try { + value = JSON.parse(row.valueJson); + } catch { + relationshipError( + "UNAVAILABLE", + "A requested candidate context value is unreadable.", + { statusCode: 503 }, + ); + } + const values = contextByPage.get(row.documentId) ?? {}; + values[row.propertyId] = value; + contextByPage.set(row.documentId, values); + } + const items: ContentRelationCandidate[] = []; + for (const document of page) { + items.push({ + pageId: document.id, + title: document.title, + context: contextByPage.get(document.id) ?? {}, + slotObservationToken: + projection.direction === "inverse" && + bundle.version.forwardCardinality === "one" + ? await slotObservation( + { + relationshipTypeId: bundle.type.id, + sourcePageId: document.id, + ownerEmail: bundle.type.ownerEmail, + orgId: bundle.type.orgId, + spaceId: bundle.type.spaceId, + }, + context, + ) + : null, + }); + } + return { + scope: "viewer-accessible", + items, + slotObservationToken: + projection.direction === "forward" && + bundle.version.forwardCardinality === "one" + ? await slotObservation( + { + relationshipTypeId: bundle.type.id, + sourcePageId: input.anchorPageId, + ownerEmail: bundle.type.ownerEmail, + orgId: bundle.type.orgId, + spaceId: bundle.type.spaceId, + }, + context, + ) + : null, + nextCursor: + offset + page.length < filtered.length + ? encodeRelationshipCursor(offset + page.length) + : null, + }; +} + +export default defineAction({ + description: + "Search caller-accessible Page candidates for one canonical relation Property, including bounded typed context.", + mcpTool: true, + schema: listContentRelationCandidatesInputSchema, + http: { method: "GET" }, + readOnly: true, + run: listContentRelationCandidates, +}); diff --git a/templates/content/actions/list-content-relationship-history.ts b/templates/content/actions/list-content-relationship-history.ts new file mode 100644 index 00000000000..9fc012802f2 --- /dev/null +++ b/templates/content/actions/list-content-relationship-history.ts @@ -0,0 +1,646 @@ +import { + defineAction, + isActionContractError, + type ActionRunContext, +} from "@agent-native/core/action"; +import { desc, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import { + listContentRelationshipHistoryInputSchema, + type ContentRelationshipHistoryChange, + type ContentRelationshipHistoryEndpoint, + type ContentRelationshipHistoryItem, + type ListContentRelationshipHistoryInput, + type ListContentRelationshipHistoryResult, +} from "../shared/relationships.js"; +import { + decodeRelationshipCursor, + encodeRelationshipCursor, + loadRelationshipDatabase, + loadRelationshipTypeBundle, + relationshipError, + resolveRelationshipDocumentAccess, +} from "./_relationship-core.js"; + +const actorSchema = z + .object({ + kind: z.enum(["person", "agent", "automation", "programmatic"]), + displayName: z.string(), + email: z.string().optional(), + runId: z.string().optional(), + networkProtocol: z.enum(["a2a", "mcp", "provider-api"]).optional(), + networkId: z.string().optional(), + networkPeer: z.string().optional(), + threadId: z.string().optional(), + turnId: z.string().optional(), + }) + .strict(); +const jsonRecordSchema = z.record(z.string(), z.unknown()); +const edgeTargetsSchema = z + .object({ + lineageId: z.string().min(1), + sourcePageId: z.string().min(1), + targetPageId: z.string().min(1), + displacedLineageIds: z.array(z.string().min(1)).optional(), + }) + .passthrough(); + +const edgeChangeKinds = { + "relationship-added": "added", + "relationship-removed": "removed", + "relationship-removed-with-projection": "removed", + "relationship-replaced": "replaced", + "relationship-add-undone": "removed", + "relationship-removal-undone": "restored", + "relationship-replacement-undone": "restored", +} as const; + +type EdgeChangeEventKind = keyof typeof edgeChangeKinds; + +function parseRecord( + value: string, + description: string, +): Record { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + relationshipError("UNAVAILABLE", `${description} is unreadable.`, { + statusCode: 503, + }); + } + const result = jsonRecordSchema.safeParse(parsed); + if (!result.success) { + relationshipError("UNAVAILABLE", `${description} is invalid.`, { + statusCode: 503, + }); + } + return result.data; +} + +function extractPageIds(value: unknown, key = ""): string[] { + if (Array.isArray(value)) { + return value.flatMap((entry) => extractPageIds(entry, key)); + } + if (!value || typeof value !== "object") { + return typeof value === "string" && /(?:^|_)pageids?$/i.test(key) + ? [value] + : []; + } + return Object.entries(value as Record).flatMap( + ([childKey, child]) => { + if ( + typeof child === "string" && + /(?:^|_)(?:source|target)?pageid$/i.test(childKey) + ) { + return [child]; + } + if (Array.isArray(child) && /(?:^|_)pageids$/i.test(childKey)) { + return child.filter( + (entry): entry is string => typeof entry === "string", + ); + } + return extractPageIds(child, childKey); + }, + ); +} + +function extractDatabaseIds(value: unknown, key = ""): string[] { + if (Array.isArray(value)) { + return value.flatMap((entry) => extractDatabaseIds(entry, key)); + } + if (!value || typeof value !== "object") { + return typeof value === "string" && /(?:^|_)databaseids?$/i.test(key) + ? [value] + : []; + } + return Object.entries(value as Record).flatMap( + ([childKey, child]) => { + if ( + typeof child === "string" && + /(?:^|_)(?:source|target|owner)?databaseid$/i.test(childKey) + ) { + return [child]; + } + if (Array.isArray(child) && /(?:^|_)databaseids$/i.test(childKey)) { + return child.filter( + (entry): entry is string => typeof entry === "string", + ); + } + return extractDatabaseIds(child, childKey); + }, + ); +} + +function edgeChangeKind(kind: string) { + return kind in edgeChangeKinds + ? edgeChangeKinds[kind as EdgeChangeEventKind] + : null; +} + +function historySummary(operation: string, eventKinds: string[]): string { + const count = eventKinds.length; + if (operation === "mutate-relationships") { + return `${count} relationship change${count === 1 ? "" : "s"}`; + } + if (operation === "remove-relation-property") { + const removals = eventKinds.filter((kind) => + kind.includes("relationship-removed"), + ).length; + return removals + ? `Removed a relation Property and ${removals} selected relationship${removals === 1 ? "" : "s"}` + : "Removed a relation Property and kept its relationships"; + } + if (operation === "configure-relation-property") { + return "Configured a relation Property"; + } + if (operation === "undo-relationship-revision") { + return `${count} compensating relationship change${count === 1 ? "" : "s"}`; + } + return `${count} committed relationship event${count === 1 ? "" : "s"}`; +} + +async function typeIsAccessible( + typeId: string, + context?: ActionRunContext, +): Promise { + const db = getDb(); + try { + const bundle = await loadRelationshipTypeBundle(typeId, { + allowArchived: true, + db, + }); + await Promise.all([ + loadRelationshipDatabase( + bundle.version.sourceDatabaseId, + "viewer", + db, + context, + { allowDeleted: true }, + ), + loadRelationshipDatabase( + bundle.version.targetDatabaseId, + "viewer", + db, + context, + { allowDeleted: true }, + ), + ]); + return true; + } catch (error) { + if (isActionContractError(error) && error.errorCode === "NOT_ACCESSIBLE") { + return false; + } + throw error; + } +} + +async function listContentRelationshipHistory( + input: ListContentRelationshipHistoryInput, + context?: ActionRunContext, +): Promise { + const db = getDb(); + let spaceId: string | null = null; + if (input.pageId) { + const access = await resolveRelationshipDocumentAccess(input.pageId, { + db, + context, + }); + if (!access?.resource.spaceId) { + relationshipError( + "NOT_ACCESSIBLE", + "The requested relationship history is not accessible.", + { statusCode: 404 }, + ); + } + spaceId = access.resource.spaceId; + } + if (input.relationshipTypeId) { + if (!(await typeIsAccessible(input.relationshipTypeId, context))) { + relationshipError( + "NOT_ACCESSIBLE", + "The requested relationship history is not accessible.", + { statusCode: 404 }, + ); + } + const bundle = await loadRelationshipTypeBundle(input.relationshipTypeId, { + allowArchived: true, + db, + }); + if (spaceId && spaceId !== bundle.type.spaceId) { + return { scope: "viewer-accessible", items: [], nextCursor: null }; + } + spaceId = bundle.type.spaceId; + } + let directRevision: + | typeof schema.contentRelationshipRevisions.$inferSelect + | null = null; + if (input.revisionId) { + [directRevision] = await db + .select() + .from(schema.contentRelationshipRevisions) + .where(eq(schema.contentRelationshipRevisions.id, input.revisionId)); + if (!directRevision || (spaceId && directRevision.spaceId !== spaceId)) { + relationshipError( + "NOT_ACCESSIBLE", + "The requested relationship history is not accessible.", + { statusCode: 404 }, + ); + } + spaceId = directRevision.spaceId; + } + if (!spaceId) { + relationshipError( + "NOT_ACCESSIBLE", + "The requested relationship history is not accessible.", + { statusCode: 404 }, + ); + } + const revisions = directRevision + ? [directRevision] + : await db + .select() + .from(schema.contentRelationshipRevisions) + .where(eq(schema.contentRelationshipRevisions.spaceId, spaceId)) + .orderBy( + desc(schema.contentRelationshipRevisions.createdAt), + desc(schema.contentRelationshipRevisions.id), + ); + const revisionIds = revisions.map((revision) => revision.id); + const events = revisionIds.length + ? await db + .select() + .from(schema.contentRelationshipEvents) + .where( + inArray(schema.contentRelationshipEvents.revisionId, revisionIds), + ) + : []; + const eventsByRevision = new Map< + string, + Array + >(); + for (const event of events) { + eventsByRevision.set(event.revisionId, [ + ...(eventsByRevision.get(event.revisionId) ?? []), + event, + ]); + } + const compensated = revisionIds.length + ? await db + .select({ + compensatesRevisionId: + schema.contentRelationshipRevisions.compensatesRevisionId, + }) + .from(schema.contentRelationshipRevisions) + .where( + inArray( + schema.contentRelationshipRevisions.compensatesRevisionId, + revisionIds, + ), + ) + : []; + const compensatedIds = new Set( + compensated.flatMap((row) => + row.compensatesRevisionId ? [row.compensatesRevisionId] : [], + ), + ); + const authorized: ContentRelationshipHistoryItem[] = []; + for (const revision of revisions) { + const revisionEvents = [...(eventsByRevision.get(revision.id) ?? [])].sort( + (left, right) => + left.sequence - right.sequence || left.id.localeCompare(right.id), + ); + if (revisionEvents.length === 0) continue; + if ( + input.relationshipTypeId && + !revisionEvents.some( + (event) => event.relationshipTypeId === input.relationshipTypeId, + ) + ) { + continue; + } + const typeIds = [ + ...new Set( + revisionEvents.flatMap((event) => + event.relationshipTypeId ? [event.relationshipTypeId] : [], + ), + ), + ]; + let accessible = true; + for (const typeId of typeIds) { + if (!(await typeIsAccessible(typeId, context))) { + accessible = false; + break; + } + } + if (!accessible) continue; + const eventRecords = revisionEvents.map((event) => ({ + event, + targets: parseRecord(event.targetsJson, "A relationship event target"), + diff: parseRecord(event.diffJson, "A relationship event diff"), + })); + const edgeRecords = eventRecords.flatMap((record) => { + const kind = edgeChangeKind(record.event.kind); + if (!kind) return []; + const targets = edgeTargetsSchema.safeParse(record.targets); + if ( + !targets.success || + !record.event.relationshipTypeId || + !record.event.relationshipTypeVersionId + ) { + relationshipError( + "UNAVAILABLE", + "A relationship history change is incomplete.", + { statusCode: 503 }, + ); + } + return [ + { + event: record.event, + kind, + targets: targets.data, + relationshipTypeId: record.event.relationshipTypeId, + relationshipTypeVersionId: record.event.relationshipTypeVersionId, + }, + ]; + }); + const displacedLineageIds = [ + ...new Set( + edgeRecords.flatMap( + (record) => record.targets.displacedLineageIds ?? [], + ), + ), + ]; + const displacedLineages = displacedLineageIds.length + ? await db + .select() + .from(schema.contentRelationshipLineages) + .where( + inArray(schema.contentRelationshipLineages.id, displacedLineageIds), + ) + : []; + if (displacedLineages.length !== displacedLineageIds.length) { + relationshipError( + "UNAVAILABLE", + "A relationship history replacement is incomplete.", + { statusCode: 503 }, + ); + } + const displacedById = new Map( + displacedLineages.map((lineage) => [lineage.id, lineage]), + ); + for (const record of edgeRecords) { + const displacedIds = record.targets.displacedLineageIds ?? []; + if (displacedIds.length > 1) { + relationshipError( + "UNAVAILABLE", + "A relationship history replacement is invalid.", + { statusCode: 503 }, + ); + } + for (const displacedId of displacedIds) { + const displaced = displacedById.get(displacedId); + if ( + !displaced || + displaced.relationshipTypeId !== record.relationshipTypeId || + displaced.sourcePageId !== record.targets.sourcePageId + ) { + relationshipError( + "UNAVAILABLE", + "A relationship history replacement is inconsistent.", + { statusCode: 503 }, + ); + } + } + } + const databaseIds = [ + ...new Set( + eventRecords.flatMap(({ targets, diff }) => [ + ...extractDatabaseIds(targets), + ...extractDatabaseIds(diff), + ]), + ), + ]; + const historyDatabases = databaseIds.length + ? await db + .select({ + id: schema.contentDatabases.id, + documentId: schema.contentDatabases.documentId, + }) + .from(schema.contentDatabases) + .where(inArray(schema.contentDatabases.id, databaseIds)) + : []; + if (historyDatabases.length !== databaseIds.length) { + relationshipError( + "UNAVAILABLE", + "A relationship history Database reference is unavailable.", + { statusCode: 503 }, + ); + } + const pageIds = [ + ...new Set([ + ...eventRecords.flatMap(({ targets, diff }) => [ + ...extractPageIds(targets), + ...extractPageIds(diff), + ]), + ...historyDatabases.map((database) => database.documentId), + ...displacedLineages.flatMap((lineage) => [ + lineage.sourcePageId, + lineage.targetPageId, + ]), + ]), + ]; + if (input.pageId && !pageIds.includes(input.pageId)) continue; + const endpoints = new Map(); + for (const pageId of pageIds) { + const access = await resolveRelationshipDocumentAccess(pageId, { + db, + context, + }); + if (!access) { + accessible = false; + break; + } + endpoints.set(pageId, { pageId, title: access.resource.title }); + } + if (!accessible) continue; + const versionIds = [ + ...new Set(edgeRecords.map((record) => record.relationshipTypeVersionId)), + ]; + const versions = versionIds.length + ? await db + .select() + .from(schema.contentRelationshipTypeVersions) + .where(inArray(schema.contentRelationshipTypeVersions.id, versionIds)) + : []; + if (versions.length !== versionIds.length) { + relationshipError( + "UNAVAILABLE", + "A relationship history definition is unavailable.", + { statusCode: 503 }, + ); + } + const versionsById = new Map( + versions.map((version) => [version.id, version]), + ); + for (const record of edgeRecords) { + const version = versionsById.get(record.relationshipTypeVersionId); + if ( + !version || + version.relationshipTypeId !== record.relationshipTypeId + ) { + relationshipError( + "UNAVAILABLE", + "A relationship history definition is inconsistent.", + { statusCode: 503 }, + ); + } + try { + await Promise.all([ + loadRelationshipDatabase( + version.sourceDatabaseId, + "viewer", + db, + context, + { allowDeleted: true }, + ), + loadRelationshipDatabase( + version.targetDatabaseId, + "viewer", + db, + context, + { allowDeleted: true }, + ), + ]); + } catch (error) { + if ( + isActionContractError(error) && + error.errorCode === "NOT_ACCESSIBLE" + ) { + accessible = false; + break; + } + throw error; + } + } + if (!accessible) continue; + const changes: ContentRelationshipHistoryChange[] = edgeRecords.map( + (record) => { + const source = endpoints.get(record.targets.sourcePageId); + const target = endpoints.get(record.targets.targetPageId); + const version = versionsById.get(record.relationshipTypeVersionId); + if (!source || !target || !version) { + relationshipError( + "UNAVAILABLE", + "A relationship history change is unavailable.", + { statusCode: 503 }, + ); + } + const displacedId = record.targets.displacedLineageIds?.[0]; + const displaced = displacedId + ? displacedById.get(displacedId) + : undefined; + const previousTarget = displaced + ? endpoints.get(displaced.targetPageId) + : undefined; + if (displaced && !previousTarget) { + relationshipError( + "UNAVAILABLE", + "A relationship history replacement target is unavailable.", + { statusCode: 503 }, + ); + } + return { + eventId: record.event.id, + kind: record.kind, + relationshipTypeId: record.relationshipTypeId, + relationshipLabel: version.forwardLabel, + source, + target, + ...(previousTarget ? { previousTarget } : {}), + }; + }, + ); + let actorValue: unknown; + try { + actorValue = JSON.parse(revision.actorJson); + } catch { + relationshipError( + "UNAVAILABLE", + "A relationship history actor is unreadable.", + { + statusCode: 503, + }, + ); + } + const actor = actorSchema.safeParse(actorValue); + if (!actor.success) { + relationshipError( + "UNAVAILABLE", + "A relationship history actor is invalid.", + { + statusCode: 503, + }, + ); + } + const diff = parseRecord( + revision.diffJson, + "A relationship history revision", + ); + const recoverable = + !compensatedIds.has(revision.id) && + ["mutate-relationships", "remove-relation-property"].includes( + revision.operation, + ); + authorized.push({ + revisionId: revision.id, + eventIds: revisionEvents.map((event) => event.id).sort(), + committedAt: revision.createdAt, + actor: actor.data, + authorizingPrincipal: parseRecord( + revision.authorizingPrincipalJson, + "A relationship history principal", + ), + origin: revision.origin, + operation: revision.operation, + summary: historySummary( + revision.operation, + revisionEvents.map((event) => event.kind), + ), + changes, + diff, + recovery: recoverable + ? { allowed: true, recoveryToken: revision.recoveryToken } + : { allowed: false }, + }); + } + if (input.revisionId && authorized.length === 0) { + relationshipError( + "NOT_ACCESSIBLE", + "The requested relationship history is not accessible.", + { statusCode: 404 }, + ); + } + const offset = decodeRelationshipCursor(input.cursor); + const page = authorized.slice(offset, offset + input.limit); + return { + scope: "viewer-accessible", + items: page, + nextCursor: + offset + page.length < authorized.length + ? encodeRelationshipCursor(offset + page.length) + : null, + }; +} + +export default defineAction({ + description: + "List caller-accessible committed canonical relationship revisions with typed attribution and recovery state.", + mcpTool: true, + schema: listContentRelationshipHistoryInputSchema, + http: { method: "GET" }, + readOnly: true, + run: listContentRelationshipHistory, +}); diff --git a/templates/content/actions/list-content-relationship-types.ts b/templates/content/actions/list-content-relationship-types.ts new file mode 100644 index 00000000000..9ef0314b945 --- /dev/null +++ b/templates/content/actions/list-content-relationship-types.ts @@ -0,0 +1,127 @@ +import { defineAction, isActionContractError } from "@agent-native/core/action"; +import { and, eq, isNull } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { + listContentRelationshipTypesInputSchema, + type ListContentRelationshipTypesInput, + type ListContentRelationshipTypesResult, +} from "../shared/relationships.js"; +import { + decodeRelationshipCursor, + encodeRelationshipCursor, + loadRelationshipDatabase, + loadRelationshipTypeBundle, + relationshipCapabilities, + relationshipProjectionDto, + relationshipTypeDto, + relationshipTypeVersionDto, + type RelationshipDatabaseContext, +} from "./_relationship-core.js"; + +async function accessibleDatabase( + databaseId: string, +): Promise { + try { + return await loadRelationshipDatabase(databaseId, "viewer"); + } catch (error) { + if (isActionContractError(error) && error.errorCode === "NOT_ACCESSIBLE") { + return null; + } + throw error; + } +} + +async function listContentRelationshipTypes( + input: ListContentRelationshipTypesInput, +): Promise { + const db = getDb(); + const anchor = await loadRelationshipDatabase(input.databaseId, "viewer", db); + const typeRows = await db + .select() + .from(schema.contentRelationshipTypes) + .where( + and( + eq(schema.contentRelationshipTypes.spaceId, anchor.database.spaceId!), + eq(schema.contentRelationshipTypes.state, "active"), + ), + ); + const items: ListContentRelationshipTypesResult["items"] = []; + for (const typeRow of typeRows) { + const bundle = await loadRelationshipTypeBundle(typeRow.id, { db }); + if ( + bundle.version.sourceDatabaseId !== input.databaseId && + bundle.version.targetDatabaseId !== input.databaseId + ) { + continue; + } + const [source, target] = await Promise.all([ + accessibleDatabase(bundle.version.sourceDatabaseId), + accessibleDatabase(bundle.version.targetDatabaseId), + ]); + if (!source || !target) continue; + const projectionRows = await db + .select() + .from(schema.contentRelationshipProjections) + .where( + and( + eq( + schema.contentRelationshipProjections.relationshipTypeId, + typeRow.id, + ), + isNull(schema.contentRelationshipProjections.archivedAt), + ), + ); + const projections = []; + for (const projection of projectionRows) { + if (await accessibleDatabase(projection.databaseId)) { + projections.push(relationshipProjectionDto(projection)); + } + } + const direction = + bundle.version.sourceDatabaseId === input.databaseId + ? "forward" + : "inverse"; + items.push({ + type: relationshipTypeDto(typeRow), + version: relationshipTypeVersionDto(bundle.version), + projections, + capabilities: relationshipCapabilities({ + databaseRole: anchor.role, + pageRole: anchor.role, + direction, + editable: + direction === "forward" || + projections.some( + (projection) => + projection.databaseId === input.databaseId && + projection.direction === "inverse" && + projection.editable, + ), + cardinality: + bundle.version.forwardCardinality === "one" ? "one" : "many", + }), + }); + } + items.sort((left, right) => left.type.id.localeCompare(right.type.id)); + const offset = decodeRelationshipCursor(input.cursor); + const page = items.slice(offset, offset + input.limit); + return { + scope: "viewer-accessible", + items: page, + nextCursor: + offset + page.length < items.length + ? encodeRelationshipCursor(offset + page.length) + : null, + }; +} + +export default defineAction({ + description: + "List supported canonical relationship types available from one readable Content database.", + mcpTool: true, + schema: listContentRelationshipTypesInputSchema, + http: { method: "GET" }, + readOnly: true, + run: listContentRelationshipTypes, +}); diff --git a/templates/content/actions/list-content-relationships.ts b/templates/content/actions/list-content-relationships.ts new file mode 100644 index 00000000000..811f712820a --- /dev/null +++ b/templates/content/actions/list-content-relationships.ts @@ -0,0 +1,14 @@ +import { defineAction } from "@agent-native/core/action"; + +import { listContentRelationshipsInputSchema } from "../shared/relationships.js"; +import { listContentRelationships } from "./_relationship-read.js"; + +export default defineAction({ + description: + "List the caller-accessible canonical typed relationships for one Page or Content database, with safe edit routes and observation tokens.", + mcpTool: true, + schema: listContentRelationshipsInputSchema, + http: { method: "GET" }, + readOnly: true, + run: listContentRelationships, +}); diff --git a/templates/content/actions/mcp-action-contract.spec.ts b/templates/content/actions/mcp-action-contract.spec.ts index ea5ed431354..eab7df0d62b 100644 --- a/templates/content/actions/mcp-action-contract.spec.ts +++ b/templates/content/actions/mcp-action-contract.spec.ts @@ -4,6 +4,7 @@ import { mcpToolInputSchema } from "../../../packages/core/src/mcp/tool-input-sc import addComment from "./add-comment.js"; import addContentDatabaseSourceFieldProperty from "./add-content-database-source-field-property.js"; import addDatabaseItem from "./add-database-item.js"; +import configureContentRelationProperty from "./configure-content-relation-property.js"; import connectNotionStatus from "./connect-notion-status.js"; import createDocument from "./create-document.js"; import deleteContentDatabase from "./delete-content-database.js"; @@ -14,12 +15,20 @@ import { resolveContentDatabaseReadLimit } from "./get-content-database.js"; import getDocument from "./get-document.js"; import listComments from "./list-comments.js"; import listContentDatabases from "./list-content-databases.js"; +import listContentRelationCandidates from "./list-content-relation-candidates.js"; +import listContentRelationshipHistory from "./list-content-relationship-history.js"; +import listContentRelationshipTypes from "./list-content-relationship-types.js"; +import listContentRelationships from "./list-content-relationships.js"; import listDocuments from "./list-documents.js"; import manageContentDatabaseMigration from "./manage-content-database-migration.js"; import migrateContentDatabaseRows from "./migrate-content-database-rows.js"; +import mutateContentRelationships from "./mutate-content-relationships.js"; import navigate from "./navigate.js"; +import prepareContentRelationshipRemoval from "./prepare-content-relationship-removal.js"; import refreshList from "./refresh-list.js"; +import removeContentRelationProperty from "./remove-content-relation-property.js"; import searchDocuments from "./search-documents.js"; +import undoContentRelationshipRevision from "./undo-content-relationship-revision.js"; import updateComment from "./update-comment.js"; import updateDatabaseItem from "./update-database-item.js"; import updateDatabaseItems from "./update-database-items.js"; @@ -29,6 +38,15 @@ import viewScreen from "./view-screen.js"; describe("Content action-owned agent catalogs", () => { const directMcpActions = { + "configure-content-relation-property": configureContentRelationProperty, + "list-content-relationship-types": listContentRelationshipTypes, + "list-content-relation-candidates": listContentRelationCandidates, + "list-content-relationships": listContentRelationships, + "mutate-content-relationships": mutateContentRelationships, + "prepare-content-relationship-removal": prepareContentRelationshipRemoval, + "remove-content-relation-property": removeContentRelationProperty, + "list-content-relationship-history": listContentRelationshipHistory, + "undo-content-relationship-revision": undoContentRelationshipRevision, "list-documents": listDocuments, "search-documents": searchDocuments, "get-document": getDocument, diff --git a/templates/content/actions/migrate-content-database-rows.db.test.ts b/templates/content/actions/migrate-content-database-rows.db.test.ts index d12cd010a06..034abb759a6 100644 --- a/templates/content/actions/migrate-content-database-rows.db.test.ts +++ b/templates/content/actions/migrate-content-database-rows.db.test.ts @@ -975,6 +975,41 @@ describe("migrate-content-database-rows", () => { ).rejects.toThrow("drifted"); }); + it("rejects canonical relationship projections in legacy and protected migration values", async () => { + const db = getDb(); + const canonicalOptionsJson = JSON.stringify({ + relation: { relationshipTypeId: "relationship-type" }, + }); + const legacySeed = await fixture(); + await db + .update(schema.documentPropertyDefinitions) + .set({ optionsJson: canonicalOptionsJson }) + .where( + eq(schema.documentPropertyDefinitions.id, legacySeed.definitions[1].id), + ); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + action.run({ phase: "validate", plan: plan(legacySeed) }), + ), + ).rejects.toMatchObject({ errorCode: "USE_RELATIONSHIP_MUTATION" }); + + const protectedSeed = await fixture(); + await db + .update(schema.documentPropertyDefinitions) + .set({ optionsJson: canonicalOptionsJson }) + .where( + eq( + schema.documentPropertyDefinitions.id, + protectedSeed.definitions[0].id, + ), + ); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + action.run({ phase: "validate", plan: plan(protectedSeed) }), + ), + ).rejects.toMatchObject({ errorCode: "USE_RELATIONSHIP_MUTATION" }); + }); + it("requires current editor access to every row before legacy cleanup", async () => { const seed = await fixture(); const input = plan(seed); diff --git a/templates/content/actions/migrate-content-database-rows.ts b/templates/content/actions/migrate-content-database-rows.ts index be510c7c9ba..b53738ba8b0 100644 --- a/templates/content/actions/migrate-content-database-rows.ts +++ b/templates/content/actions/migrate-content-database-rows.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { bodyRevisionForContent } from "../server/lib/document-body-revision.js"; +import { assertNotCanonicalRelationProjection } from "./_canonical-relation-guard.js"; import { lockContentDatabaseMutation, touchContentDatabase, @@ -661,6 +662,14 @@ export async function runMigration(args: MigrationInput) { const rollback = parseJson(receipt.rollbackJson); const now = new Date().toISOString(); if (args.phase === "rollback") { + const ids: string[] = rollback.createdPropertyIds ?? []; + for (const definition of current.definitions) { + if (!ids.includes(definition.id)) continue; + assertNotCanonicalRelationProjection( + definition, + "Canonical relationship projections cannot be removed by migration rollback.", + ); + } for (const prior of rollback.versions ?? []) { const [version] = await tx .select() @@ -687,7 +696,6 @@ export async function runMigration(args: MigrationInput) { `Rollback row ${prior.documentId} changed concurrently.`, ); } - const ids = rollback.createdPropertyIds ?? []; if (ids.length) { await tx .delete(schema.documentPropertyValues) @@ -761,6 +769,12 @@ export async function runMigration(args: MigrationInput) { ) ) throw new Error("Legacy property is missing or unsafe to finalize."); + for (const definition of legacy) { + assertNotCanonicalRelationProjection( + definition, + "Canonical relationship projections cannot be removed by migration finalization.", + ); + } if (legacyIds.length) { await tx .delete(schema.documentPropertyValues) diff --git a/templates/content/actions/mutate-content-relationships.ts b/templates/content/actions/mutate-content-relationships.ts new file mode 100644 index 00000000000..bb04beba817 --- /dev/null +++ b/templates/content/actions/mutate-content-relationships.ts @@ -0,0 +1,814 @@ +import { defineAction, type ActionRunContext } from "@agent-native/core/action"; +import { and, eq, inArray, isNotNull, sql } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { + mutateContentRelationshipsInputSchema, + type MutateContentRelationshipsInput, + type MutateContentRelationshipsResult, + type RelationshipChange, + type RelationshipInvalidation, + type RelationshipMutationResultItem, +} from "../shared/relationships.js"; +import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; +import { nanoid } from "./_property-utils.js"; +import { + authorizeRelationshipRoute, + type AuthorizedRelationshipRoute, +} from "./_relationship-authority.js"; +import { + activeActivationIdsForLineages, + appendRelationshipEvent, + createRelationshipRevision, + emptyRelationshipInvalidation, + insertRelationshipReceipt, + loadRelationshipDatabase, + loadRelationshipTypeBundle, + lockRelationshipCardinalitySlots, + lockRelationshipOperation, + lockRelationshipLineages, + lockRelationshipTypes, + mergeRelationshipInvalidation, + relationshipActorContext, + relationshipError, + relationshipRequestHash, + requireRelationshipDocumentAccess, + replayRelationshipReceipt, + retireRelationshipActivations, + type RelationshipDb, + type RelationshipTypeBundle, +} from "./_relationship-core.js"; + +type ChangePlan = { + change: RelationshipChange; + bundle: RelationshipTypeBundle; + sourcePageId: string; + targetPageId: string; + edgeId?: string; +}; + +async function assertRelationshipReceiptAccessible( + db: RelationshipDb, + result: MutateContentRelationshipsResult, + context?: ActionRunContext, +): Promise { + const edgeIds = [ + ...new Set( + result.results.flatMap((item) => [ + item.edgeId, + ...(item.displacedEdgeIds ?? []), + ]), + ), + ]; + const lineages = edgeIds.length + ? await db + .select() + .from(schema.contentRelationshipLineages) + .where(inArray(schema.contentRelationshipLineages.id, edgeIds)) + : []; + if (lineages.length !== edgeIds.length) { + relationshipError( + "UNAVAILABLE", + "The committed relationship receipt is incomplete.", + { statusCode: 503 }, + ); + } + const bundles = new Map(); + for (const lineage of lineages) { + let bundle = bundles.get(lineage.relationshipTypeId); + if (!bundle) { + bundle = await loadRelationshipTypeBundle(lineage.relationshipTypeId, { + allowArchived: true, + db, + }); + await Promise.all([ + loadRelationshipDatabase( + bundle.version.sourceDatabaseId, + "viewer", + db, + context, + { allowDeleted: true }, + ), + loadRelationshipDatabase( + bundle.version.targetDatabaseId, + "viewer", + db, + context, + { allowDeleted: true }, + ), + ]); + bundles.set(lineage.relationshipTypeId, bundle); + } + await Promise.all([ + requireRelationshipDocumentAccess(lineage.sourcePageId, "viewer", { + db, + context, + }), + requireRelationshipDocumentAccess(lineage.targetPageId, "viewer", { + db, + context, + }), + ]); + } +} + +function deduplicateChanges( + changes: RelationshipChange[], +): RelationshipChange[] { + const seenAdds = new Set(); + return changes.filter((change) => { + if (change.kind !== "add") return true; + const key = `${change.typeId}\u0000${change.sourcePageId}\u0000${change.targetPageId}`; + if (seenAdds.has(key)) return false; + seenAdds.add(key); + return true; + }); +} + +async function planChanges( + changes: RelationshipChange[], + db: RelationshipDb, +): Promise { + const removeIds = changes.flatMap((change) => + change.kind === "remove" ? [change.edgeId] : [], + ); + const removeRows = removeIds.length + ? await db + .select() + .from(schema.contentRelationshipLineages) + .where(inArray(schema.contentRelationshipLineages.id, removeIds)) + : []; + const removeById = new Map( + removeRows.map((lineage) => [lineage.id, lineage]), + ); + const typeIds = [ + ...new Set( + changes.map((change) => { + if (change.kind !== "remove") return change.typeId; + const lineage = removeById.get(change.edgeId); + if (!lineage) { + relationshipError( + "INVALID_TARGET", + "The requested relationship edge is unavailable.", + { statusCode: 404 }, + ); + } + return lineage.relationshipTypeId; + }), + ), + ]; + const bundles = new Map(); + for (const typeId of typeIds) { + const onlyRemovals = changes.every((change) => { + if (change.kind !== "remove") return change.typeId !== typeId; + return removeById.get(change.edgeId)?.relationshipTypeId === typeId; + }); + bundles.set( + typeId, + await loadRelationshipTypeBundle(typeId, { + allowArchived: onlyRemovals, + db, + }), + ); + } + return changes.map((change) => { + if (change.kind === "remove") { + const lineage = removeById.get(change.edgeId); + if (!lineage) { + relationshipError( + "INVALID_TARGET", + "The requested relationship edge is unavailable.", + { statusCode: 404 }, + ); + } + return { + change, + bundle: bundles.get(lineage.relationshipTypeId)!, + sourcePageId: lineage.sourcePageId, + targetPageId: lineage.targetPageId, + edgeId: lineage.id, + }; + } + const bundle = bundles.get(change.typeId)!; + if (bundle.version.id !== change.typeVersionId) { + relationshipError( + "TYPE_UNAVAILABLE", + "The relationship type version changed; refresh before editing.", + { statusCode: 409 }, + ); + } + return { + change, + bundle, + sourcePageId: change.sourcePageId, + targetPageId: change.targetPageId, + }; + }); +} + +function validateBatchShape(plans: ChangePlan[]): void { + const spaces = new Set(plans.map((plan) => plan.bundle.type.spaceId)); + const tenants = new Set( + plans.map((plan) => + plan.bundle.type.orgId + ? `org:${plan.bundle.type.orgId}` + : `owner:${plan.bundle.type.ownerEmail.toLowerCase()}`, + ), + ); + if (spaces.size !== 1 || tenants.size !== 1) { + relationshipError( + "INVALID_TARGET", + "One relationship mutation cannot cross Content spaces or tenants.", + ); + } + const oneTargets = new Map>(); + for (const plan of plans) { + if ( + plan.change.kind === "remove" || + plan.bundle.version.forwardCardinality !== "one" + ) { + continue; + } + const key = `${plan.bundle.type.id}\u0000${plan.sourcePageId}`; + const targets = oneTargets.get(key) ?? new Set(); + targets.add(plan.targetPageId); + oneTargets.set(key, targets); + } + if ([...oneTargets.values()].some((targets) => targets.size > 1)) { + relationshipError( + "CARDINALITY_VIOLATION", + "A max-one relationship batch cannot choose multiple targets for one source Page.", + { statusCode: 409 }, + ); + } +} + +async function liveLineagesForSlot( + tx: RelationshipDb, + typeId: string, + sourcePageId: string, +) { + const lineages = await tx + .select() + .from(schema.contentRelationshipLineages) + .where( + and( + eq(schema.contentRelationshipLineages.relationshipTypeId, typeId), + eq(schema.contentRelationshipLineages.sourcePageId, sourcePageId), + ), + ); + const activeByLineage = await activeActivationIdsForLineages( + tx, + lineages.map((lineage) => lineage.id), + ); + const deletedTargets = lineages.length + ? await tx + .select({ pageId: schema.contentRelationshipEndpointStates.pageId }) + .from(schema.contentRelationshipEndpointStates) + .where( + and( + inArray( + schema.contentRelationshipEndpointStates.pageId, + lineages.map((lineage) => lineage.targetPageId), + ), + isNotNull( + schema.contentRelationshipEndpointStates.permanentlyDeletedAt, + ), + ), + ) + : []; + const deletedTargetIds = new Set(deletedTargets.map((row) => row.pageId)); + return lineages.filter( + (lineage) => + (activeByLineage.get(lineage.id)?.length ?? 0) > 0 && + !deletedTargetIds.has(lineage.targetPageId), + ); +} + +async function getOrCreateLineage( + tx: RelationshipDb, + plan: ChangePlan, + actorEmail: string, +) { + const candidateId = nanoid(24); + await tx + .insert(schema.contentRelationshipLineages) + .values({ + id: candidateId, + ownerEmail: plan.bundle.type.ownerEmail, + orgId: plan.bundle.type.orgId, + spaceId: plan.bundle.type.spaceId, + relationshipTypeId: plan.bundle.type.id, + sourcePageId: plan.sourcePageId, + targetPageId: plan.targetPageId, + createdBy: actorEmail, + }) + .onConflictDoNothing(); + const [lineage] = await tx + .select() + .from(schema.contentRelationshipLineages) + .where( + and( + eq( + schema.contentRelationshipLineages.relationshipTypeId, + plan.bundle.type.id, + ), + eq(schema.contentRelationshipLineages.sourcePageId, plan.sourcePageId), + eq(schema.contentRelationshipLineages.targetPageId, plan.targetPageId), + ), + ); + if (!lineage) { + relationshipError( + "UNAVAILABLE", + "The relationship lineage was not committed.", + { + statusCode: 503, + }, + ); + } + await lockRelationshipLineages(tx, [lineage.id]); + return lineage; +} + +async function validateObservation( + tx: RelationshipDb, + args: { + token: string; + kind: "edge" | "slot"; + edgeId?: string; + typeId: string; + sourcePageId: string; + activationIds?: string[]; + context?: ActionRunContext; + }, +) { + const actor = relationshipActorContext(args.context); + const [observation] = await tx + .select() + .from(schema.contentRelationshipObservations) + .where(eq(schema.contentRelationshipObservations.token, args.token)); + if ( + !observation || + observation.callerScope !== actor.callerScope || + observation.kind !== args.kind || + observation.edgeId !== (args.edgeId ?? null) || + observation.relationshipTypeId !== args.typeId || + observation.sourcePageId !== args.sourcePageId || + observation.expiresAt <= new Date().toISOString() + ) { + relationshipError( + "STALE_SELECTION", + "The relationship observation is stale or does not match this edit.", + { statusCode: 409 }, + ); + } + let observedIds: string[]; + try { + observedIds = JSON.parse(observation.activationIdsJson) as string[]; + } catch { + relationshipError( + "UNAVAILABLE", + "The relationship observation is unreadable.", + { + statusCode: 503, + }, + ); + } + observedIds = [...new Set(observedIds!)].sort(); + if ( + args.activationIds && + JSON.stringify([...new Set(args.activationIds)].sort()) !== + JSON.stringify(observedIds) + ) { + relationshipError( + "STALE_SELECTION", + "The removal does not match the observed relationship activations.", + { statusCode: 409 }, + ); + } + return observedIds; +} + +async function mutateContentRelationships( + input: MutateContentRelationshipsInput, + context?: ActionRunContext, +): Promise { + const changes = deduplicateChanges(input.changes); + const db = getDb(); + let plans = await planChanges(changes, db); + validateBatchShape(plans); + const preflightRoutes: AuthorizedRelationshipRoute[] = []; + for (const plan of plans) { + preflightRoutes.push( + await authorizeRelationshipRoute({ + db, + bundle: plan.bundle, + sourcePageId: plan.sourcePageId, + targetPageId: plan.targetPageId, + route: plan.change.route, + operation: plan.change.kind, + context, + }), + ); + } + const requestHash = relationshipRequestHash({ ...input, changes }); + const actor = relationshipActorContext(context); + const firstBundle = plans[0]!.bundle; + const tenant = { + ownerEmail: firstBundle.type.ownerEmail, + orgId: firstBundle.type.orgId, + spaceId: firstBundle.type.spaceId, + }; + + return db.transaction(async (rawTx) => { + const tx = rawTx as unknown as RelationshipDb; + await lockRelationshipOperation(tx, { + tenant, + operationId: input.operationId, + context, + }); + const databaseIds = [ + ...new Set([ + ...preflightRoutes.flatMap((route) => route.databaseIds), + ...plans.flatMap((plan) => + plan.change.kind === "remove" + ? [] + : [ + plan.bundle.version.sourceDatabaseId, + plan.bundle.version.targetDatabaseId, + ], + ), + ]), + ].sort(); + for (const databaseId of databaseIds) { + await lockContentDatabaseMutation(tx, databaseId); + } + await lockRelationshipTypes( + tx, + plans.map((plan) => plan.bundle.type.id), + ); + plans = await planChanges(changes, tx); + for (const plan of plans) { + await authorizeRelationshipRoute({ + db: tx, + bundle: plan.bundle, + sourcePageId: plan.sourcePageId, + targetPageId: plan.targetPageId, + route: plan.change.route, + operation: plan.change.kind, + context, + }); + } + await lockRelationshipLineages( + tx, + plans.flatMap((plan) => (plan.edgeId ? [plan.edgeId] : [])), + ); + await lockRelationshipCardinalitySlots( + tx, + plans + .filter((plan) => plan.bundle.version.forwardCardinality === "one") + .map((plan) => ({ + ownerEmail: plan.bundle.type.ownerEmail, + orgId: plan.bundle.type.orgId, + spaceId: plan.bundle.type.spaceId, + relationshipTypeId: plan.bundle.type.id, + sourcePageId: plan.sourcePageId, + })), + ); + const replayed = + await replayRelationshipReceipt(tx, { + spaceId: tenant.spaceId, + operationId: input.operationId, + requestHash, + context, + }); + if (replayed) { + await assertRelationshipReceiptAccessible(tx, replayed, context); + return replayed; + } + + const revision = await createRelationshipRevision(tx, { + tenant, + operationId: input.operationId, + operation: "mutate-relationships", + diff: { requestedChanges: changes }, + context, + }); + const results: RelationshipMutationResultItem[] = []; + const invalidation: RelationshipInvalidation = + emptyRelationshipInvalidation(); + + for (const plan of plans) { + mergeRelationshipInvalidation(invalidation, { + pageIds: [plan.sourcePageId, plan.targetPageId], + databaseIds: [ + plan.bundle.version.sourceDatabaseId, + plan.bundle.version.targetDatabaseId, + ], + relationshipTypeIds: [plan.bundle.type.id], + }); + if (plan.change.kind === "add") { + if (plan.bundle.version.forwardCardinality === "one") { + const occupied = await liveLineagesForSlot( + tx, + plan.bundle.type.id, + plan.sourcePageId, + ); + if ( + occupied.some( + (lineage) => lineage.targetPageId !== plan.targetPageId, + ) + ) { + relationshipError( + "CARDINALITY_VIOLATION", + "This max-one relationship already has a target; use replace with a fresh observation.", + { statusCode: 409 }, + ); + } + } + const lineage = await getOrCreateLineage( + tx, + plan, + actor.actor.displayName, + ); + const eventId = nanoid(24); + const activationId = nanoid(24); + await appendRelationshipEvent(tx, revision, { + tenant, + eventId, + kind: "relationship-added", + relationshipTypeId: plan.bundle.type.id, + relationshipTypeVersionId: plan.bundle.version.id, + route: plan.change.route, + targets: { + lineageId: lineage.id, + sourcePageId: lineage.sourcePageId, + targetPageId: lineage.targetPageId, + }, + diff: { addedActivationIds: [activationId] }, + }); + await tx.insert(schema.contentRelationshipActivations).values({ + id: activationId, + ownerEmail: tenant.ownerEmail, + orgId: tenant.orgId, + spaceId: tenant.spaceId, + lineageId: lineage.id, + addedEventId: eventId, + createdBy: actor.actor.displayName, + }); + if (plan.bundle.version.forwardCardinality === "one") { + await tx + .update(schema.contentRelationshipCardinalitySlots) + .set({ + lineageId: lineage.id, + targetPageId: lineage.targetPageId, + updatedAt: new Date().toISOString(), + }) + .where( + and( + eq( + schema.contentRelationshipCardinalitySlots.relationshipTypeId, + plan.bundle.type.id, + ), + eq( + schema.contentRelationshipCardinalitySlots.sourcePageId, + plan.sourcePageId, + ), + ), + ); + } + results.push({ + kind: "add", + edgeId: lineage.id, + lineageId: lineage.id, + state: "active", + activationIds: [activationId], + }); + continue; + } + + if (plan.change.kind === "remove") { + const observedIds = await validateObservation(tx, { + token: plan.change.observationToken, + kind: "edge", + edgeId: plan.edgeId, + typeId: plan.bundle.type.id, + sourcePageId: plan.sourcePageId, + activationIds: plan.change.observedActivationIds, + context, + }); + const eventId = nanoid(24); + const retiredIds = await retireRelationshipActivations(tx, { + activationIds: observedIds, + eventId, + tenant, + actorEmail: actor.actor.displayName, + }); + await appendRelationshipEvent(tx, revision, { + tenant, + eventId, + kind: "relationship-removed", + relationshipTypeId: plan.bundle.type.id, + relationshipTypeVersionId: plan.bundle.version.id, + route: plan.change.route, + targets: { + lineageId: plan.edgeId, + sourcePageId: plan.sourcePageId, + targetPageId: plan.targetPageId, + }, + diff: { retiredActivationIds: retiredIds }, + }); + const remaining = await activeActivationIdsForLineages(tx, [ + plan.edgeId!, + ]); + if ((remaining.get(plan.edgeId!)?.length ?? 0) === 0) { + await tx + .update(schema.contentRelationshipCardinalitySlots) + .set({ + lineageId: null, + targetPageId: null, + updatedAt: new Date().toISOString(), + }) + .where( + and( + eq( + schema.contentRelationshipCardinalitySlots.relationshipTypeId, + plan.bundle.type.id, + ), + eq( + schema.contentRelationshipCardinalitySlots.sourcePageId, + plan.sourcePageId, + ), + eq( + schema.contentRelationshipCardinalitySlots.lineageId, + plan.edgeId!, + ), + ), + ); + } + results.push({ + kind: "remove", + edgeId: plan.edgeId!, + lineageId: plan.edgeId!, + state: + (remaining.get(plan.edgeId!)?.length ?? 0) > 0 + ? "active" + : "inactive", + activationIds: retiredIds, + }); + continue; + } + + if (plan.bundle.version.forwardCardinality !== "one") { + relationshipError( + "UNSUPPORTED_CONFIGURATION", + "Replace is supported only for max-one relationships.", + ); + } + await validateObservation(tx, { + token: plan.change.observedSlotToken, + kind: "slot", + typeId: plan.bundle.type.id, + sourcePageId: plan.sourcePageId, + context, + }); + const occupied = await liveLineagesForSlot( + tx, + plan.bundle.type.id, + plan.sourcePageId, + ); + const displaced = occupied.filter( + (lineage) => lineage.targetPageId !== plan.targetPageId, + ); + for (const lineage of displaced) { + try { + await authorizeRelationshipRoute({ + db: tx, + bundle: plan.bundle, + sourcePageId: lineage.sourcePageId, + targetPageId: lineage.targetPageId, + route: plan.change.route, + operation: "remove", + context, + }); + } catch { + relationshipError( + "ROUTE_NOT_AUTHORIZED", + "The max-one relationship cannot be replaced through this route.", + { statusCode: 403 }, + ); + } + } + const eventId = nanoid(24); + const displacedActivationIds = ( + await Promise.all( + displaced.map(async (lineage) => { + const current = await activeActivationIdsForLineages(tx, [ + lineage.id, + ]); + return retireRelationshipActivations(tx, { + activationIds: current.get(lineage.id) ?? [], + eventId, + tenant, + actorEmail: actor.actor.displayName, + }); + }), + ) + ).flat(); + const lineage = await getOrCreateLineage( + tx, + plan, + actor.actor.displayName, + ); + const activationId = nanoid(24); + await appendRelationshipEvent(tx, revision, { + tenant, + eventId, + kind: "relationship-replaced", + relationshipTypeId: plan.bundle.type.id, + relationshipTypeVersionId: plan.bundle.version.id, + route: plan.change.route, + targets: { + lineageId: lineage.id, + sourcePageId: lineage.sourcePageId, + targetPageId: lineage.targetPageId, + displacedLineageIds: displaced.map((entry) => entry.id), + }, + diff: { + addedActivationIds: [activationId], + retiredActivationIds: displacedActivationIds, + }, + }); + await tx.insert(schema.contentRelationshipActivations).values({ + id: activationId, + ownerEmail: tenant.ownerEmail, + orgId: tenant.orgId, + spaceId: tenant.spaceId, + lineageId: lineage.id, + addedEventId: eventId, + createdBy: actor.actor.displayName, + }); + await tx + .update(schema.contentRelationshipCardinalitySlots) + .set({ + lineageId: lineage.id, + targetPageId: lineage.targetPageId, + updatedAt: new Date().toISOString(), + }) + .where( + and( + eq( + schema.contentRelationshipCardinalitySlots.relationshipTypeId, + plan.bundle.type.id, + ), + eq( + schema.contentRelationshipCardinalitySlots.sourcePageId, + plan.sourcePageId, + ), + ), + ); + results.push({ + kind: "replace", + edgeId: lineage.id, + lineageId: lineage.id, + state: "active", + activationIds: [activationId], + displacedEdgeIds: displaced.map((entry) => entry.id), + }); + } + + await tx + .update(schema.contentRelationshipRevisions) + .set({ + diffJson: JSON.stringify({ requestedChanges: changes, results }), + }) + .where(eq(schema.contentRelationshipRevisions.id, revision.revisionId)); + const receiptId = nanoid(24); + const result: MutateContentRelationshipsResult = { + operationId: input.operationId, + receiptId, + revisionId: revision.revisionId, + eventIds: revision.eventIds, + results, + invalidation, + }; + await insertRelationshipReceipt(tx, { + id: receiptId, + tenant, + operationId: input.operationId, + requestHash, + revisionId: revision.revisionId, + result, + context, + }); + return result; + }); +} + +export default defineAction({ + description: + "Atomically add, remove, or replace canonical typed relationships using explicit authorized routes and observations.", + mcpTool: true, + schema: mutateContentRelationshipsInputSchema, + run: mutateContentRelationships, +}); diff --git a/templates/content/actions/permanently-delete-document.ts b/templates/content/actions/permanently-delete-document.ts index 9667aa86042..7638b0a72d5 100644 --- a/templates/content/actions/permanently-delete-document.ts +++ b/templates/content/actions/permanently-delete-document.ts @@ -17,7 +17,7 @@ export default defineAction({ schema: z.object({ id: z.string().describe("Trashed root document ID"), }), - run: async ({ id }) => { + run: async ({ id }, context) => { const access = await assertAccess("document", id, "admin"); const db = getDb(); let deleted: string[] | undefined; @@ -28,6 +28,7 @@ export default defineAction({ tx as unknown as ReturnType, id, access.resource.ownerEmail as string, + context, ), ); break; diff --git a/templates/content/actions/prepare-content-relationship-removal.ts b/templates/content/actions/prepare-content-relationship-removal.ts new file mode 100644 index 00000000000..19b976127be --- /dev/null +++ b/templates/content/actions/prepare-content-relationship-removal.ts @@ -0,0 +1,306 @@ +import { + defineAction, + isActionContractError, + type ActionRunContext, +} from "@agent-native/core/action"; +import { and, eq, inArray, isNull } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import { + prepareContentRelationshipRemovalInputSchema, + type PrepareContentRelationshipRemovalInput, + type PrepareContentRelationshipRemovalResult, + type RelationshipRouteRef, + relationshipRouteRefSchema, +} from "../shared/relationships.js"; +import { nanoid } from "./_property-utils.js"; +import { authorizeRelationshipRoute } from "./_relationship-authority.js"; +import { + activeActivationIdsForLineages, + loadRelationshipDatabase, + loadRelationshipTypeBundle, + relationshipActorContext, + relationshipError, +} from "./_relationship-core.js"; +import { availableRelationshipRoutes } from "./_relationship-read.js"; + +const REMOVAL_SELECTION_TTL_MS = 15 * 60 * 1_000; +const MAX_REMOVAL_SELECTION = 100; + +export const relationshipRemovalSelectionEntrySchema = z + .object({ + edgeId: z.string().min(1), + typeId: z.string().min(1), + typeVersionId: z.string().min(1), + sourcePageId: z.string().min(1), + targetPageId: z.string().min(1), + observedActivationIds: z.array(z.string().min(1)).min(1).max(100), + route: relationshipRouteRefSchema, + }) + .strict(); +export type RelationshipRemovalSelectionEntry = z.infer< + typeof relationshipRemovalSelectionEntrySchema +>; + +function projectionRoute( + projection: typeof schema.contentRelationshipProjections.$inferSelect, + sourcePageId: string, + targetPageId: string, +): RelationshipRouteRef { + return projection.direction === "inverse" + ? { + kind: "inverse-property", + propertyId: projection.propertyId, + targetPageId, + } + : { + kind: "forward-property", + propertyId: projection.propertyId, + sourcePageId, + }; +} + +async function prepareContentRelationshipRemoval( + input: PrepareContentRelationshipRemovalInput, + context?: ActionRunContext, +): Promise { + const db = getDb(); + let property: + | typeof schema.contentRelationshipProjections.$inferSelect + | null = null; + let lineages: Array; + if (input.selection.kind === "property") { + const [projection] = await db + .select() + .from(schema.contentRelationshipProjections) + .where( + and( + eq( + schema.contentRelationshipProjections.propertyId, + input.selection.propertyId, + ), + isNull(schema.contentRelationshipProjections.archivedAt), + ), + ); + if (!projection) { + relationshipError( + "NOT_ACCESSIBLE", + "The requested relation Property is not accessible.", + { statusCode: 404 }, + ); + } + await loadRelationshipDatabase(projection.databaseId, "admin", db, context); + property = projection; + const memberships = await db + .select({ documentId: schema.contentDatabaseItems.documentId }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, projection.databaseId)); + const anchorIds = memberships.map((membership) => membership.documentId); + lineages = anchorIds.length + ? await db + .select() + .from(schema.contentRelationshipLineages) + .where( + and( + eq( + schema.contentRelationshipLineages.relationshipTypeId, + projection.relationshipTypeId, + ), + projection.direction === "inverse" + ? inArray( + schema.contentRelationshipLineages.targetPageId, + anchorIds, + ) + : inArray( + schema.contentRelationshipLineages.sourcePageId, + anchorIds, + ), + ), + ) + : []; + } else { + lineages = await db + .select() + .from(schema.contentRelationshipLineages) + .where( + inArray(schema.contentRelationshipLineages.id, input.selection.edgeIds), + ); + if (lineages.length !== new Set(input.selection.edgeIds).size) { + relationshipError( + "NOT_ACCESSIBLE", + "A requested relationship edge is not accessible.", + { statusCode: 404 }, + ); + } + } + if (input.filter?.typeId) { + lineages = lineages.filter( + (lineage) => lineage.relationshipTypeId === input.filter!.typeId, + ); + } + if (input.filter?.oppositePageId) { + lineages = lineages.filter( + (lineage) => + lineage.sourcePageId === input.filter!.oppositePageId || + lineage.targetPageId === input.filter!.oppositePageId, + ); + } + if (input.filter?.direction && property) { + const desiredDirection = input.filter.direction; + lineages = lineages.filter(() => + desiredDirection === "both" + ? true + : property!.direction === "forward" + ? desiredDirection === "outgoing" + : desiredDirection === "incoming", + ); + } + const activeByLineage = await activeActivationIdsForLineages( + db, + lineages.map((lineage) => lineage.id), + ); + const selected: RelationshipRemovalSelectionEntry[] = []; + const selectedTenants = new Set(); + for (const lineage of lineages.sort((left, right) => + left.id.localeCompare(right.id), + )) { + const activationIds = activeByLineage.get(lineage.id) ?? []; + if (activationIds.length === 0) continue; + const bundle = await loadRelationshipTypeBundle( + lineage.relationshipTypeId, + { + allowArchived: true, + db, + }, + ); + selectedTenants.add( + `${bundle.type.spaceId}\u0000${bundle.type.orgId ?? ""}\u0000${bundle.type.ownerEmail.toLowerCase()}`, + ); + let route: RelationshipRouteRef | undefined; + if (property) { + const candidate = projectionRoute( + property, + lineage.sourcePageId, + lineage.targetPageId, + ); + try { + await authorizeRelationshipRoute({ + db, + bundle, + sourcePageId: lineage.sourcePageId, + targetPageId: lineage.targetPageId, + route: candidate, + operation: "remove", + context, + }); + route = candidate; + } catch (error) { + if ( + !isActionContractError(error) || + ![ + "NOT_ACCESSIBLE", + "ROUTE_NOT_AUTHORIZED", + "SOURCE_AUTHORITY_UNSUPPORTED", + ].includes(error.errorCode) + ) { + throw error; + } + continue; + } + } else { + route = ( + await availableRelationshipRoutes(db, { + bundle, + sourcePageId: lineage.sourcePageId, + targetPageId: lineage.targetPageId, + context, + }) + )[0]; + } + if (!route) continue; + selected.push({ + edgeId: lineage.id, + typeId: lineage.relationshipTypeId, + typeVersionId: bundle.version.id, + sourcePageId: lineage.sourcePageId, + targetPageId: lineage.targetPageId, + observedActivationIds: activationIds, + route, + }); + } + if ( + input.selection.kind === "edges" && + selected.length !== new Set(input.selection.edgeIds).size + ) { + relationshipError( + "NOT_ACCESSIBLE", + "A requested relationship edge is not accessible.", + { statusCode: 404 }, + ); + } + if (selected.length > MAX_REMOVAL_SELECTION) { + relationshipError( + "LIMIT_EXCEEDED", + `The removal selection exceeds ${MAX_REMOVAL_SELECTION} accessible relationships. Narrow the filter and try again.`, + ); + } + const firstBundle = selected[0] + ? await loadRelationshipTypeBundle(selected[0].typeId, { + allowArchived: true, + db, + }) + : property + ? await loadRelationshipTypeBundle(property.relationshipTypeId, { + allowArchived: true, + db, + }) + : null; + if (!firstBundle) { + relationshipError( + "INVALID_TARGET", + "An edge selection must contain at least one live accessible relationship.", + ); + } + if (selectedTenants.size > 1) { + relationshipError( + "INVALID_TARGET", + "One removal selection cannot cross Content spaces.", + ); + } + const actor = relationshipActorContext(context); + const selectionReceipt = nanoid(32); + const recoveryToken = nanoid(32); + const expiresAt = new Date( + Date.now() + REMOVAL_SELECTION_TTL_MS, + ).toISOString(); + await db.insert(schema.contentRelationshipRemovalSelections).values({ + token: selectionReceipt, + ownerEmail: firstBundle.type.ownerEmail, + orgId: firstBundle.type.orgId, + spaceId: firstBundle.type.spaceId, + callerScope: actor.callerScope, + propertyId: property?.propertyId ?? null, + selectionJson: JSON.stringify(selected), + recoveryToken, + expiresAt, + }); + return { + selectionReceipt, + selectedCount: selected.length, + edges: selected.map((entry) => ({ + edgeId: entry.edgeId, + observedActivationIds: entry.observedActivationIds, + })), + expiresAt, + recoveryToken, + }; +} + +export default defineAction({ + description: + "Freeze an exact caller-accessible relationship activation selection before a destructive relation Property removal.", + mcpTool: true, + schema: prepareContentRelationshipRemovalInputSchema, + run: prepareContentRelationshipRemoval, +}); diff --git a/templates/content/actions/relationship-concurrency.postgres.test.ts b/templates/content/actions/relationship-concurrency.postgres.test.ts new file mode 100644 index 00000000000..43a6672d874 --- /dev/null +++ b/templates/content/actions/relationship-concurrency.postgres.test.ts @@ -0,0 +1,517 @@ +import { runWithRequestContext } from "@agent-native/core/server"; +import { eq, inArray, sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const POSTGRES_URL = process.env.CONTENT_RELATIONSHIP_POSTGRES_URL; +const ORIGINAL_DATABASE_URL = process.env.DATABASE_URL; +const owner = "relationship-concurrency-owner@example.test"; + +let getDb: () => any; +let schema: typeof import("../server/db/schema.js"); +let configure: typeof import("./configure-content-relation-property.js").default; +let mutate: typeof import("./mutate-content-relationships.js").default; +let list: typeof import("./list-content-relationships.js").default; +let nextFixture = 0; +let spaceId: string; + +const asOwner = (run: () => Promise) => + runWithRequestContext({ userEmail: owner }, run); + +beforeAll(async () => { + if (!POSTGRES_URL) return; + const databaseName = new URL(POSTGRES_URL).pathname.slice(1).toLowerCase(); + if (!databaseName.includes("test")) { + throw new Error( + "CONTENT_RELATIONSHIP_POSTGRES_URL must name an isolated test database.", + ); + } + process.env.DATABASE_URL = POSTGRES_URL; + const database = await import("../server/db/index.js"); + getDb = database.getDb; + schema = database.schema; + configure = (await import("./configure-content-relation-property.js")) + .default; + mutate = (await import("./mutate-content-relationships.js")).default; + list = (await import("./list-content-relationships.js")).default; + await (await import("../server/plugins/db.js")).default(undefined as never); + + spaceId = `relationship-concurrency-${process.pid}-${Date.now()}-${Math.random() + .toString(36) + .slice(2)}`; + const filesDatabaseId = `${spaceId}-files`; + await getDb().insert(schema.contentSpaces).values({ + id: spaceId, + name: "Relationship concurrency", + kind: "personal", + ownerEmail: owner, + filesDatabaseId, + createdBy: owner, + }); + await getDb() + .insert(schema.documents) + .values({ + id: `${filesDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Files", + }); + await getDb() + .insert(schema.contentDatabases) + .values({ + id: filesDatabaseId, + documentId: `${filesDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Files", + systemRole: "files", + blocksSeeded: 1, + }); +}, 60_000); + +afterAll(() => { + if (ORIGINAL_DATABASE_URL === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = ORIGINAL_DATABASE_URL; +}); + +type Fixture = Awaited>; + +async function fixture(cardinality: "one" | "many" = "many") { + const prefix = `${spaceId}-${++nextFixture}`; + const sourceDatabaseId = `${prefix}-deliverables`; + const targetDatabaseId = `${prefix}-people`; + const sourcePageId = `${prefix}-launch`; + const targetPageIds = [`${prefix}-mira`, `${prefix}-jo`, `${prefix}-sam`]; + await getDb() + .insert(schema.documents) + .values([ + { + id: `${sourceDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Campaign deliverables", + }, + { + id: `${targetDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Marketing team", + }, + { + id: sourcePageId, + spaceId, + ownerEmail: owner, + title: "Launch article", + }, + ...targetPageIds.map((id, index) => ({ + id, + spaceId, + ownerEmail: owner, + title: ["Mira", "Jo", "Sam"][index]!, + })), + ]); + await getDb() + .insert(schema.contentDatabases) + .values([ + { + id: sourceDatabaseId, + spaceId, + ownerEmail: owner, + documentId: `${sourceDatabaseId}-page`, + title: "Deliverables", + blocksSeeded: 1, + }, + { + id: targetDatabaseId, + spaceId, + ownerEmail: owner, + documentId: `${targetDatabaseId}-page`, + title: "People", + blocksSeeded: 1, + }, + ]); + await getDb() + .insert(schema.contentDatabaseItems) + .values([ + { + id: `${prefix}-source-item`, + databaseId: sourceDatabaseId, + documentId: sourcePageId, + ownerEmail: owner, + }, + ...targetPageIds.map((documentId, index) => ({ + id: `${prefix}-target-item-${index}`, + databaseId: targetDatabaseId, + documentId, + ownerEmail: owner, + position: index, + })), + ]); + const configured = await asOwner(() => + configure.run({ + ownerDatabaseId: sourceDatabaseId, + alias: cardinality === "one" ? "Assignee" : "Contributors", + operationId: `${prefix}-configure`, + definition: { + kind: "new-local", + forwardLabel: cardinality === "one" ? "Assigned to" : "Contributes to", + inverseLabel: "Deliverables", + forwardCardinality: cardinality, + sourceDatabaseId, + targetDatabaseId, + }, + inverseProjection: { + ownerDatabaseId: targetDatabaseId, + alias: "Deliverables", + editable: true, + }, + }), + ); + return { + prefix, + sourceDatabaseId, + targetDatabaseId, + sourcePageId, + targetPageIds, + typeId: configured.relationshipType.id, + typeVersionId: configured.relationshipTypeVersion.id, + propertyId: configured.projection.propertyId, + }; +} + +function addInput(seed: Fixture, operationId: string, targetPageId: string) { + return { + operationId, + changes: [ + { + kind: "add" as const, + typeId: seed.typeId, + typeVersionId: seed.typeVersionId, + sourcePageId: seed.sourcePageId, + targetPageId, + route: { + kind: "forward-property" as const, + propertyId: seed.propertyId, + sourcePageId: seed.sourcePageId, + }, + }, + ], + }; +} + +async function add(seed: Fixture, operationId: string, targetPageId: string) { + return asOwner(() => mutate.run(addInput(seed, operationId, targetPageId))); +} + +async function sourceRelationships(seed: Fixture) { + return asOwner(() => + list.run({ + pageId: seed.sourcePageId, + relationshipTypeId: seed.typeId, + direction: "outgoing", + }), + ); +} + +async function proveSeparatePostgresTransactions() { + let arrivals = 0; + let release!: () => void; + const bothConnectionsArrived = new Promise((resolve) => { + release = resolve; + }); + const enter = () => + getDb().transaction(async (tx: any) => { + const result = await tx.execute(sql`select pg_backend_pid() as pid`); + const rows = Array.isArray(result) ? result : result.rows; + const pid = Number(rows[0].pid); + arrivals += 1; + if (arrivals === 2) release(); + await bothConnectionsArrived; + return pid; + }); + const pids = await Promise.all([enter(), enter()]); + expect(new Set(pids).size).toBe(2); +} + +const postgresSuite = POSTGRES_URL ? describe : describe.skip; + +postgresSuite("typed relationship PostgreSQL concurrency", () => { + it("executes overlapping transactions on separate PostgreSQL connections", async () => { + await proveSeparatePostgresTransactions(); + }); + + it("converges concurrent adds of the same tuple on one visible edge", async () => { + const seed = await fixture(); + const [first, second] = await Promise.all([ + add(seed, `${seed.prefix}-add-a`, seed.targetPageIds[0]!), + add(seed, `${seed.prefix}-add-b`, seed.targetPageIds[0]!), + ]); + + expect(first.results[0]!.edgeId).toBe(second.results[0]!.edgeId); + expect(first.results[0]!.activationIds).not.toEqual( + second.results[0]!.activationIds, + ); + const current = await sourceRelationships(seed); + expect(current.items).toHaveLength(1); + expect(current.items[0]!.observedActivationIds.sort()).toEqual( + [ + ...first.results[0]!.activationIds, + ...second.results[0]!.activationIds, + ].sort(), + ); + }); + + it("preserves an unseen concurrent add when removing a stale observation", async () => { + const seed = await fixture(); + const initial = await add( + seed, + `${seed.prefix}-initial-add`, + seed.targetPageIds[0]!, + ); + const observed = (await sourceRelationships(seed)).items[0]!; + const removeInput = { + operationId: `${seed.prefix}-stale-remove`, + changes: [ + { + kind: "remove" as const, + edgeId: observed.edgeId, + observedActivationIds: observed.observedActivationIds, + observationToken: observed.observationToken, + route: { + kind: "forward-property" as const, + propertyId: seed.propertyId, + sourcePageId: seed.sourcePageId, + }, + }, + ], + }; + const [removed, added] = await Promise.all([ + asOwner(() => mutate.run(removeInput)), + add(seed, `${seed.prefix}-concurrent-add`, seed.targetPageIds[0]!), + ]); + + expect(removed.results[0]!.activationIds).toEqual( + initial.results[0]!.activationIds, + ); + const current = await sourceRelationships(seed); + expect(current.items).toHaveLength(1); + expect(current.items[0]!.observedActivationIds).toEqual( + added.results[0]!.activationIds, + ); + }); + + it("replays a concurrent repeated operation with one stable receipt", async () => { + const seed = await fixture(); + const input = addInput( + seed, + `${seed.prefix}-repeated-operation`, + seed.targetPageIds[0]!, + ); + const [first, second] = await Promise.all([ + asOwner(() => mutate.run(input)), + asOwner(() => mutate.run(input)), + ]); + + expect(second).toEqual(first); + const current = await sourceRelationships(seed); + expect(current.items).toHaveLength(1); + expect(current.items[0]!.observedActivationIds).toEqual( + first.results[0]!.activationIds, + ); + const receipts = await getDb() + .select() + .from(schema.contentRelationshipReceipts) + .where( + eq(schema.contentRelationshipReceipts.operationId, input.operationId), + ); + expect(receipts).toHaveLength(1); + }); + + it("rejects a concurrent operation ID collision with a typed conflict", async () => { + const seed = await fixture(); + const operationId = `${seed.prefix}-conflicting-operation`; + const settled = await Promise.allSettled([ + add(seed, operationId, seed.targetPageIds[0]!), + add(seed, operationId, seed.targetPageIds[1]!), + ]); + const fulfilled = settled.filter( + ( + result, + ): result is PromiseFulfilledResult>> => + result.status === "fulfilled", + ); + const rejected = settled.filter( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0]!.reason).toMatchObject({ + errorCode: "IDEMPOTENCY_CONFLICT", + }); + const current = await sourceRelationships(seed); + expect(current.items).toHaveLength(1); + expect(current.items[0]!.edgeId).toBe( + fulfilled[0]!.value.results[0]!.edgeId, + ); + }); + + it("serializes conflicting operation IDs across disjoint database pairs", async () => { + const first = await fixture(); + const second = await fixture(); + const operationId = `${first.prefix}-cross-database-operation`; + const outcomes = await Promise.allSettled([ + add(first, operationId, first.targetPageIds[0]!), + add(second, operationId, second.targetPageIds[0]!), + ]); + expect( + outcomes.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + const denied = outcomes.find((result) => result.status === "rejected"); + expect(denied?.status === "rejected" && denied.reason).toMatchObject({ + errorCode: "IDEMPOTENCY_CONFLICT", + }); + const [firstState, secondState] = await Promise.all([ + sourceRelationships(first), + sourceRelationships(second), + ]); + expect(firstState.items.length + secondState.items.length).toBe(1); + }); + + it("serializes concurrent max-one replacements with explicit history", async () => { + const seed = await fixture("one"); + await add(seed, `${seed.prefix}-initial-choice`, seed.targetPageIds[0]!); + const observed = (await sourceRelationships(seed)).items[0]!; + expect(observed.slotObservationToken).toBeTruthy(); + const replaceInput = (targetPageId: string, suffix: string) => ({ + operationId: `${seed.prefix}-replace-${suffix}`, + changes: [ + { + kind: "replace" as const, + typeId: seed.typeId, + typeVersionId: seed.typeVersionId, + sourcePageId: seed.sourcePageId, + targetPageId, + observedSlotToken: observed.slotObservationToken!, + route: { + kind: "forward-property" as const, + propertyId: seed.propertyId, + sourcePageId: seed.sourcePageId, + }, + }, + ], + }); + const settled = await Promise.allSettled([ + asOwner(() => mutate.run(replaceInput(seed.targetPageIds[1]!, "jo"))), + asOwner(() => mutate.run(replaceInput(seed.targetPageIds[2]!, "sam"))), + ]); + const fulfilled = settled.filter( + ( + result, + ): result is PromiseFulfilledResult< + Awaited> + > => result.status === "fulfilled", + ); + const rejected = settled.filter( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + + expect(fulfilled.length).toBeGreaterThanOrEqual(1); + for (const failure of rejected) { + expect(failure.reason).toMatchObject({ errorCode: "STALE_SELECTION" }); + } + const current = await sourceRelationships(seed); + expect(current.items).toHaveLength(1); + expect(seed.targetPageIds.slice(1)).toContain( + current.items[0]!.targetPageId, + ); + expect( + fulfilled.some( + ({ value }) => value.results[0]!.edgeId === current.items[0]!.edgeId, + ), + ).toBe(true); + + const revisionIds = fulfilled.map(({ value }) => value.revisionId); + const revisions = await getDb() + .select() + .from(schema.contentRelationshipRevisions) + .where(inArray(schema.contentRelationshipRevisions.id, revisionIds)); + const events = await getDb() + .select() + .from(schema.contentRelationshipEvents) + .where(inArray(schema.contentRelationshipEvents.revisionId, revisionIds)); + expect(revisions).toHaveLength(fulfilled.length); + expect(events).toHaveLength(fulfilled.length); + expect( + events.every((event: any) => event.kind === "relationship-replaced"), + ).toBe(true); + for (const success of fulfilled) { + expect(success.value.results[0]).toMatchObject({ + kind: "replace", + state: "active", + }); + expect( + success.value.results[0]!.displacedEdgeIds?.length, + ).toBeGreaterThan(0); + } + }); + + it("serializes first admission with endpoint trash and restores only committed knowledge", async () => { + const seed = await fixture(); + const trash = (await import("./delete-document.js")).default; + const restore = (await import("./restore-document.js")).default; + const target = seed.targetPageIds[0]!; + const [admission, deletion] = await Promise.allSettled([ + add(seed, `${seed.prefix}-race-add-trash`, target), + asOwner(() => trash.run({ id: target })), + ]); + expect(deletion.status).toBe("fulfilled"); + if (admission.status === "rejected") { + expect(admission.reason).toMatchObject({ errorCode: "INVALID_TARGET" }); + } + const trashed = await sourceRelationships(seed); + expect(trashed.items.every((edge) => edge.state !== "active")).toBe(true); + await asOwner(() => restore.run({ id: target })); + const restored = await sourceRelationships(seed); + expect(restored.items).toHaveLength( + admission.status === "fulfilled" ? 1 : 0, + ); + if (admission.status === "fulfilled") { + expect(restored.items[0]).toMatchObject({ + edgeId: admission.value.results[0]!.edgeId, + state: "active", + }); + } + }); + + it("does not resurrect an observed removal committed concurrently with trash", async () => { + const seed = await fixture(); + const target = seed.targetPageIds[0]!; + await add(seed, `${seed.prefix}-before-trash`, target); + const observed = (await sourceRelationships(seed)).items[0]!; + const trash = (await import("./delete-document.js")).default; + const restore = (await import("./restore-document.js")).default; + await Promise.all([ + asOwner(() => + mutate.run({ + operationId: `${seed.prefix}-remove-with-trash`, + changes: [ + { + kind: "remove", + edgeId: observed.edgeId, + observedActivationIds: observed.observedActivationIds, + observationToken: observed.observationToken, + route: { + kind: "forward-property", + propertyId: seed.propertyId, + sourcePageId: seed.sourcePageId, + }, + }, + ], + }), + ), + asOwner(() => trash.run({ id: target })), + ]); + await asOwner(() => restore.run({ id: target })); + expect((await sourceRelationships(seed)).items).toHaveLength(0); + }); +}); diff --git a/templates/content/actions/relationship-services.db.test.ts b/templates/content/actions/relationship-services.db.test.ts new file mode 100644 index 00000000000..bb4cece862b --- /dev/null +++ b/templates/content/actions/relationship-services.db.test.ts @@ -0,0 +1,1165 @@ +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runWithRequestContext } from "@agent-native/core/server"; +import { and, eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const databasePath = join( + tmpdir(), + `relationship-services-${process.pid}-${Date.now()}.pglite`, +); +const owner = "relationship-services-owner@example.test"; +const viewer = "relationship-services-viewer@example.test"; +const spaceId = `relationship-services-${process.pid}-${Date.now()}`; + +let dbModule: typeof import("../server/db/index.js"); +let configure: typeof import("./configure-content-relation-property.js").default; +let listCandidates: typeof import("./list-content-relation-candidates.js").default; +let listTypes: typeof import("./list-content-relationship-types.js").default; +let listRelationships: typeof import("./list-content-relationships.js").default; +let mutate: typeof import("./mutate-content-relationships.js").default; +let prepareRemoval: typeof import("./prepare-content-relationship-removal.js").default; +let removeProperty: typeof import("./remove-content-relation-property.js").default; +let listHistory: typeof import("./list-content-relationship-history.js").default; +let nextFixture = 0; + +const asUser = (userEmail: string, run: () => Promise) => + runWithRequestContext({ userEmail }, run); +const asOwner = (run: () => Promise) => asUser(owner, run); +const asViewer = (run: () => Promise) => asUser(viewer, run); + +beforeAll(async () => { + process.env.DATABASE_URL = `pglite:${databasePath}`; + dbModule = await import("../server/db/index.js"); + await (await import("../server/plugins/db.js")).default(undefined as never); + configure = (await import("./configure-content-relation-property.js")) + .default; + listCandidates = (await import("./list-content-relation-candidates.js")) + .default; + listTypes = (await import("./list-content-relationship-types.js")).default; + listRelationships = (await import("./list-content-relationships.js")).default; + mutate = (await import("./mutate-content-relationships.js")).default; + prepareRemoval = (await import("./prepare-content-relationship-removal.js")) + .default; + removeProperty = (await import("./remove-content-relation-property.js")) + .default; + listHistory = (await import("./list-content-relationship-history.js")) + .default; + + const filesDatabaseId = `${spaceId}-files`; + await dbModule.getDb().insert(dbModule.schema.contentSpaces).values({ + id: spaceId, + name: "Relationship services", + kind: "personal", + ownerEmail: owner, + filesDatabaseId, + createdBy: owner, + }); + await dbModule + .getDb() + .insert(dbModule.schema.documents) + .values({ + id: `${filesDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Files", + }); + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabases) + .values({ + id: filesDatabaseId, + documentId: `${filesDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Files", + systemRole: "files", + blocksSeeded: 1, + }); +}); + +afterAll(() => { + delete process.env.DATABASE_URL; + rmSync(databasePath, { recursive: true, force: true }); +}); + +async function databaseFixture() { + const prefix = `${spaceId}-${++nextFixture}`; + const sourceDatabaseId = `${prefix}-deliverables`; + const targetDatabaseId = `${prefix}-people`; + const sourcePageId = `${prefix}-launch`; + const targetPageIds = [`${prefix}-mira`, `${prefix}-jo`]; + await dbModule + .getDb() + .insert(dbModule.schema.documents) + .values([ + { + id: `${sourceDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Campaign deliverables", + }, + { + id: `${targetDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Marketing team", + }, + { + id: sourcePageId, + spaceId, + ownerEmail: owner, + title: "Launch article", + }, + { + id: targetPageIds[0], + spaceId, + ownerEmail: owner, + title: "Visible Mira", + }, + { + id: targetPageIds[1], + spaceId, + ownerEmail: owner, + title: "Hidden Jo", + }, + ]); + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabases) + .values([ + { + id: sourceDatabaseId, + spaceId, + ownerEmail: owner, + documentId: `${sourceDatabaseId}-page`, + title: "Deliverables", + blocksSeeded: 1, + }, + { + id: targetDatabaseId, + spaceId, + ownerEmail: owner, + documentId: `${targetDatabaseId}-page`, + title: "People", + blocksSeeded: 1, + }, + ]); + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabaseItems) + .values([ + { + id: `${prefix}-source-item`, + databaseId: sourceDatabaseId, + documentId: sourcePageId, + ownerEmail: owner, + }, + ...targetPageIds.map((documentId, index) => ({ + id: `${prefix}-target-item-${index}`, + databaseId: targetDatabaseId, + documentId, + ownerEmail: owner, + position: index, + })), + ]); + return { + prefix, + sourceDatabaseId, + targetDatabaseId, + sourcePageId, + targetPageIds, + }; +} + +type DatabaseFixture = Awaited>; + +async function relationshipFixture(cardinality: "one" | "many" = "many") { + const seed = await databaseFixture(); + const configured = await asOwner(() => + configure.run({ + ownerDatabaseId: seed.sourceDatabaseId, + alias: cardinality === "one" ? "Assignee" : "Contributors", + operationId: `${seed.prefix}-configure`, + definition: { + kind: "new-local", + forwardLabel: "Contributes to", + inverseLabel: "Deliverables", + forwardCardinality: cardinality, + sourceDatabaseId: seed.sourceDatabaseId, + targetDatabaseId: seed.targetDatabaseId, + }, + inverseProjection: { + ownerDatabaseId: seed.targetDatabaseId, + alias: "Deliverables", + editable: true, + }, + }), + ); + return { + ...seed, + typeId: configured.relationshipType.id, + typeVersionId: configured.relationshipTypeVersion.id, + propertyId: configured.projection.propertyId, + inversePropertyId: configured.inverseProjection!.propertyId, + }; +} + +type RelationshipFixture = Awaited>; + +function addInput( + seed: RelationshipFixture, + operationId: string, + targetPageId = seed.targetPageIds[0], +) { + return { + operationId, + changes: [ + { + kind: "add" as const, + typeId: seed.typeId, + typeVersionId: seed.typeVersionId, + sourcePageId: seed.sourcePageId, + targetPageId, + route: { + kind: "forward-property" as const, + propertyId: seed.propertyId, + sourcePageId: seed.sourcePageId, + }, + }, + ], + }; +} + +async function add( + seed: RelationshipFixture, + operationId: string, + targetPageId = seed.targetPageIds[0], +) { + return asOwner(() => mutate.run(addInput(seed, operationId, targetPageId))); +} + +async function shareWithViewer( + documentIds: string[], + role: "viewer" | "editor" = "viewer", +) { + await dbModule + .getDb() + .insert(dbModule.schema.documentShares) + .values( + documentIds.map((resourceId, index) => ({ + id: `${resourceId}-viewer-share-${index}`, + resourceId, + principalType: "user", + principalId: viewer, + role, + createdBy: owner, + })), + ); +} + +function sourceDatabasePage(seed: DatabaseFixture) { + return `${seed.sourceDatabaseId}-page`; +} + +function targetDatabasePage(seed: DatabaseFixture) { + return `${seed.targetDatabaseId}-page`; +} + +describe("typed relationship service boundaries", () => { + it("strictly rejects unsupported relationship definition fields", async () => { + const seed = await databaseFixture(); + const before = await dbModule + .getDb() + .select({ id: dbModule.schema.contentRelationshipTypes.id }) + .from(dbModule.schema.contentRelationshipTypes) + .where(eq(dbModule.schema.contentRelationshipTypes.spaceId, spaceId)); + await expect( + asOwner(() => + configure.run({ + ownerDatabaseId: seed.sourceDatabaseId, + alias: "Unsupported", + operationId: `${seed.prefix}-unsupported`, + definition: { + kind: "new-local", + forwardLabel: "Assigned to", + inverseLabel: "Assignments", + forwardCardinality: "many", + sourceDatabaseId: seed.sourceDatabaseId, + targetDatabaseId: seed.targetDatabaseId, + selectorKind: "query", + }, + symmetric: true, + } as never), + ), + ).rejects.toThrow(); + + const after = await dbModule + .getDb() + .select({ id: dbModule.schema.contentRelationshipTypes.id }) + .from(dbModule.schema.contentRelationshipTypes) + .where(eq(dbModule.schema.contentRelationshipTypes.spaceId, spaceId)); + expect(after).toHaveLength(before.length); + }); + + it("rejects relation-valued candidate context and filters before search", async () => { + const seed = await relationshipFixture(); + await shareWithViewer([ + sourceDatabasePage(seed), + targetDatabasePage(seed), + seed.sourcePageId, + seed.targetPageIds[0], + ]); + + await expect( + asViewer(() => + listCandidates.run({ + propertyId: seed.propertyId, + anchorPageId: seed.sourcePageId, + search: "", + contextPropertyIds: [seed.inversePropertyId], + }), + ), + ).rejects.toMatchObject({ errorCode: "UNSUPPORTED_CONFIGURATION" }); + + const hiddenSearch = await asViewer(() => + listCandidates.run({ + propertyId: seed.propertyId, + anchorPageId: seed.sourcePageId, + search: "Hidden Jo", + contextPropertyIds: [], + }), + ); + expect(hiddenSearch).toMatchObject({ + scope: "viewer-accessible", + items: [], + nextCursor: null, + }); + const visibleSearch = await asViewer(() => + listCandidates.run({ + propertyId: seed.propertyId, + anchorPageId: seed.sourcePageId, + search: "Visible Mira", + contextPropertyIds: [], + }), + ); + expect(visibleSearch.items.map((item) => item.pageId)).toEqual([ + seed.targetPageIds[0], + ]); + }); + + it("propagates an unreadable current definition instead of returning absence", async () => { + const seed = await relationshipFixture(); + const [version] = await dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipTypeVersions) + .where( + eq( + dbModule.schema.contentRelationshipTypeVersions.id, + seed.typeVersionId, + ), + ); + expect(version).toBeTruthy(); + try { + await dbModule + .getDb() + .delete(dbModule.schema.contentRelationshipTypeVersions) + .where( + eq( + dbModule.schema.contentRelationshipTypeVersions.id, + seed.typeVersionId, + ), + ); + + await expect( + asOwner(() => + listCandidates.run({ + propertyId: seed.propertyId, + anchorPageId: seed.sourcePageId, + search: "", + contextPropertyIds: [], + }), + ), + ).rejects.toMatchObject({ errorCode: "UNAVAILABLE" }); + await expect( + asOwner(() => listTypes.run({ databaseId: seed.sourceDatabaseId })), + ).rejects.toMatchObject({ errorCode: "UNAVAILABLE" }); + } finally { + if (version) { + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipTypeVersions) + .values(version); + } + } + }); + + it("hides types, edges, and history when their full definition is not readable", async () => { + const seed = await relationshipFixture(); + const added = await add(seed, `${seed.prefix}-add`); + await shareWithViewer([sourceDatabasePage(seed), seed.sourcePageId]); + + const types = await asViewer(() => + listTypes.run({ databaseId: seed.sourceDatabaseId }), + ); + expect(types.items).toEqual([]); + const edges = await asViewer(() => + listRelationships.run({ + pageId: seed.sourcePageId, + relationshipTypeId: seed.typeId, + direction: "outgoing", + }), + ); + expect(edges.items).toEqual([]); + const ambientHistory = await asViewer(() => + listHistory.run({ pageId: seed.sourcePageId }), + ); + expect(ambientHistory.items).toEqual([]); + await expect( + asViewer(() => listHistory.run({ revisionId: added.revisionId })), + ).rejects.toMatchObject({ errorCode: "NOT_ACCESSIBLE" }); + }); + + it("returns display-ready typed history changes in canonical direction", async () => { + const seed = await relationshipFixture(); + const added = await add(seed, `${seed.prefix}-add`); + const sourceHistory = await asOwner(() => + listHistory.run({ pageId: seed.sourcePageId }), + ); + expect( + sourceHistory.items.find((item) => item.revisionId === added.revisionId) + ?.changes, + ).toEqual([ + expect.objectContaining({ + kind: "added", + relationshipTypeId: seed.typeId, + relationshipLabel: "Contributes to", + source: { pageId: seed.sourcePageId, title: "Launch article" }, + target: { pageId: seed.targetPageIds[0], title: "Visible Mira" }, + }), + ]); + const targetHistory = await asOwner(() => + listHistory.run({ pageId: seed.targetPageIds[0] }), + ); + expect( + targetHistory.items.find((item) => item.revisionId === added.revisionId) + ?.changes[0]?.relationshipLabel, + ).toBe("Contributes to"); + }); + + it("omits replacement history when its previous target is hidden", async () => { + const seed = await relationshipFixture("one"); + await add(seed, `${seed.prefix}-add`, seed.targetPageIds[0]); + const [observed] = ( + await asOwner(() => + listRelationships.run({ + pageId: seed.sourcePageId, + relationshipTypeId: seed.typeId, + direction: "outgoing", + }), + ) + ).items; + const replaced = await asOwner(() => + mutate.run({ + operationId: `${seed.prefix}-replace`, + changes: [ + { + kind: "replace", + typeId: seed.typeId, + typeVersionId: seed.typeVersionId, + sourcePageId: seed.sourcePageId, + targetPageId: seed.targetPageIds[1], + observedSlotToken: observed!.slotObservationToken!, + route: observed!.routes[0]!, + }, + ], + }), + ); + await shareWithViewer([ + sourceDatabasePage(seed), + targetDatabasePage(seed), + seed.targetPageIds[1], + ]); + await shareWithViewer([seed.sourcePageId], "editor"); + + const history = await asViewer(() => + listHistory.run({ pageId: seed.sourcePageId }), + ); + expect(history.items.map((item) => item.revisionId)).not.toContain( + replaced.revisionId, + ); + }); + + it("includes durable Property recovery in the owning Database Page history", async () => { + const seed = await relationshipFixture(); + await add(seed, `${seed.prefix}-add`); + const removed = await asOwner(() => + removeProperty.run({ + propertyId: seed.propertyId, + relationshipMode: { kind: "keep" }, + operationId: `${seed.prefix}-remove-property`, + }), + ); + const history = await asOwner(() => + listHistory.run({ pageId: sourceDatabasePage(seed) }), + ); + expect( + history.items.find((item) => item.revisionId === removed.revisionId), + ).toMatchObject({ + operation: "remove-relation-property", + changes: [], + recovery: { + allowed: true, + recoveryToken: removed.undo.recoveryToken, + }, + }); + }); + + it("fails an explicit removal selection when any requested edge is hidden", async () => { + const seed = await relationshipFixture(); + const visible = await add( + seed, + `${seed.prefix}-visible-add`, + seed.targetPageIds[0], + ); + const hidden = await add( + seed, + `${seed.prefix}-hidden-add`, + seed.targetPageIds[1], + ); + await shareWithViewer([ + sourceDatabasePage(seed), + targetDatabasePage(seed), + seed.targetPageIds[0], + ]); + await shareWithViewer([seed.sourcePageId], "editor"); + + const visibleSelection = await asViewer(() => + prepareRemoval.run({ + selection: { kind: "edges", edgeIds: [visible.results[0]!.edgeId] }, + }), + ); + expect(visibleSelection.edges.map((entry) => entry.edgeId)).toEqual([ + visible.results[0]!.edgeId, + ]); + + for (const edgeIds of [ + [hidden.results[0]!.edgeId], + [visible.results[0]!.edgeId, hidden.results[0]!.edgeId], + [`${seed.prefix}-missing-edge`], + ]) { + await expect( + asViewer(() => + prepareRemoval.run({ selection: { kind: "edges", edgeIds } }), + ), + ).rejects.toMatchObject({ + errorCode: "NOT_ACCESSIBLE", + message: "A requested relationship edge is not accessible.", + }); + } + }); + + it("keeps edges by default and removes only a prepared activation snapshot", async () => { + const kept = await relationshipFixture(); + await add(kept, `${kept.prefix}-initial-add`); + await asOwner(() => + removeProperty.run({ + propertyId: kept.propertyId, + relationshipMode: { kind: "keep" }, + operationId: `${kept.prefix}-remove-keep`, + }), + ); + const keptEdges = await asOwner(() => + listRelationships.run({ + pageId: kept.sourcePageId, + relationshipTypeId: kept.typeId, + direction: "outgoing", + }), + ); + expect(keptEdges.items).toHaveLength(1); + + const exact = await relationshipFixture(); + const initial = await add(exact, `${exact.prefix}-initial-add`); + const prepared = await asOwner(() => + prepareRemoval.run({ + selection: { kind: "property", propertyId: exact.propertyId }, + }), + ); + expect(prepared.selectedCount).toBe(1); + expect(prepared.edges[0]!.observedActivationIds).toEqual( + initial.results[0]!.activationIds, + ); + const concurrent = await add(exact, `${exact.prefix}-concurrent-add`); + const laterEdge = await add( + exact, + `${exact.prefix}-later-edge`, + exact.targetPageIds[1], + ); + const operationId = `${exact.prefix}-remove-exact`; + await expect( + asOwner(() => + removeProperty.run({ + propertyId: exact.propertyId, + relationshipMode: { + kind: "remove-selected", + selectionReceipt: prepared.selectionReceipt, + edgeIds: [`${exact.prefix}-not-in-receipt`], + }, + operationId, + }), + ), + ).rejects.toMatchObject({ errorCode: "STALE_SELECTION" }); + const [definitionAfterRejection] = await dbModule + .getDb() + .select({ id: dbModule.schema.documentPropertyDefinitions.id }) + .from(dbModule.schema.documentPropertyDefinitions) + .where( + eq(dbModule.schema.documentPropertyDefinitions.id, exact.propertyId), + ); + expect(definitionAfterRejection?.id).toBe(exact.propertyId); + const removed = await asOwner(() => + removeProperty.run({ + propertyId: exact.propertyId, + relationshipMode: { + kind: "remove-selected", + selectionReceipt: prepared.selectionReceipt, + edgeIds: [initial.results[0]!.edgeId], + }, + operationId, + }), + ); + const replayed = await asOwner(() => + removeProperty.run({ + propertyId: exact.propertyId, + relationshipMode: { + kind: "remove-selected", + selectionReceipt: prepared.selectionReceipt, + edgeIds: [initial.results[0]!.edgeId], + }, + operationId, + }), + ); + expect(replayed).toEqual(removed); + expect(removed.removedEdgeIds).toEqual([initial.results[0]!.edgeId]); + const surviving = await asOwner(() => + listRelationships.run({ + pageId: exact.sourcePageId, + relationshipTypeId: exact.typeId, + direction: "outgoing", + }), + ); + expect(surviving.items).toHaveLength(2); + expect( + surviving.items.find((item) => item.edgeId === initial.results[0]!.edgeId) + ?.observedActivationIds, + ).toEqual(concurrent.results[0]!.activationIds); + expect( + surviving.items.find( + (item) => item.edgeId === laterEdge.results[0]!.edgeId, + )?.observedActivationIds, + ).toEqual(laterEdge.results[0]!.activationIds); + }); + + it("fails closed instead of detaching an inconsistent source mapping", async () => { + const seed = await relationshipFixture(); + const sourceId = `${seed.prefix}-source`; + const fieldId = `${seed.prefix}-source-field`; + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabaseSources) + .values({ + id: sourceId, + ownerEmail: owner, + databaseId: seed.sourceDatabaseId, + sourceType: "test", + sourceName: "Test source", + sourceTable: "items", + }); + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabaseSourceFields) + .values({ + id: fieldId, + ownerEmail: owner, + sourceId, + propertyId: seed.propertyId, + localFieldKey: seed.propertyId, + sourceFieldKey: "relationship", + sourceFieldLabel: "Relationship", + sourceFieldType: "text", + }); + + await expect( + asOwner(() => + removeProperty.run({ + propertyId: seed.propertyId, + relationshipMode: { kind: "keep" }, + operationId: `${seed.prefix}-mapped-remove`, + }), + ), + ).rejects.toMatchObject({ errorCode: "SOURCE_AUTHORITY_UNSUPPORTED" }); + const [mapping] = await dbModule + .getDb() + .select({ + propertyId: dbModule.schema.contentDatabaseSourceFields.propertyId, + }) + .from(dbModule.schema.contentDatabaseSourceFields) + .where(eq(dbModule.schema.contentDatabaseSourceFields.id, fieldId)); + const [definition] = await dbModule + .getDb() + .select({ id: dbModule.schema.documentPropertyDefinitions.id }) + .from(dbModule.schema.documentPropertyDefinitions) + .where( + eq(dbModule.schema.documentPropertyDefinitions.id, seed.propertyId), + ); + expect(mapping?.propertyId).toBe(seed.propertyId); + expect(definition?.id).toBe(seed.propertyId); + }); + + it("keeps historic edges readable and removable after an admission database is deleted", async () => { + const many = await relationshipFixture(); + const added = await add(many, `${many.prefix}-initial-add`); + const deletedAt = new Date().toISOString(); + await dbModule + .getDb() + .update(dbModule.schema.contentDatabases) + .set({ deletedAt, updatedAt: deletedAt }) + .where(eq(dbModule.schema.contentDatabases.id, many.targetDatabaseId)); + + const connections = await asOwner(() => + listRelationships.run({ + pageId: many.sourcePageId, + relationshipTypeId: many.typeId, + direction: "outgoing", + }), + ); + expect(connections.items.map((item) => item.edgeId)).toEqual([ + added.results[0]!.edgeId, + ]); + const history = await asOwner(() => + listHistory.run({ pageId: many.sourcePageId }), + ); + expect(history.items.map((item) => item.revisionId)).toContain( + added.revisionId, + ); + await expect( + add(many, `${many.prefix}-blocked-add`, many.targetPageIds[1]), + ).rejects.toMatchObject({ errorCode: "CONSTRAINT_UNAVAILABLE" }); + + const observed = connections.items[0]!; + const removed = await asOwner(() => + mutate.run({ + operationId: `${many.prefix}-connections-remove`, + changes: [ + { + kind: "remove", + edgeId: observed.edgeId, + observedActivationIds: observed.observedActivationIds, + observationToken: observed.observationToken, + route: { + kind: "connections-forward", + sourcePageId: many.sourcePageId, + }, + }, + ], + }), + ); + expect(removed.results[0]).toMatchObject({ + edgeId: observed.edgeId, + kind: "remove", + state: "inactive", + }); + + const one = await relationshipFixture("one"); + await add(one, `${one.prefix}-initial-add`); + const oneObserved = ( + await asOwner(() => + listRelationships.run({ + pageId: one.sourcePageId, + relationshipTypeId: one.typeId, + direction: "outgoing", + }), + ) + ).items[0]!; + await dbModule + .getDb() + .update(dbModule.schema.contentDatabases) + .set({ deletedAt, updatedAt: deletedAt }) + .where(eq(dbModule.schema.contentDatabases.id, one.targetDatabaseId)); + await expect( + asOwner(() => + mutate.run({ + operationId: `${one.prefix}-blocked-replace`, + changes: [ + { + kind: "replace", + typeId: one.typeId, + typeVersionId: one.typeVersionId, + sourcePageId: one.sourcePageId, + targetPageId: one.targetPageIds[1], + observedSlotToken: oneObserved.slotObservationToken!, + route: { + kind: "forward-property", + propertyId: one.propertyId, + sourcePageId: one.sourcePageId, + }, + }, + ], + }), + ), + ).rejects.toMatchObject({ errorCode: "CONSTRAINT_UNAVAILABLE" }); + }); + + it("rechecks displaced-edge access before replaying a replace receipt", async () => { + const seed = await relationshipFixture("one"); + await add(seed, `${seed.prefix}-initial-add`); + await shareWithViewer( + [ + sourceDatabasePage(seed), + targetDatabasePage(seed), + seed.sourcePageId, + ...seed.targetPageIds, + ], + "editor", + ); + const observed = ( + await asViewer(() => + listRelationships.run({ + pageId: seed.sourcePageId, + relationshipTypeId: seed.typeId, + direction: "outgoing", + }), + ) + ).items[0]!; + const input = { + operationId: `${seed.prefix}-replace`, + changes: [ + { + kind: "replace" as const, + typeId: seed.typeId, + typeVersionId: seed.typeVersionId, + sourcePageId: seed.sourcePageId, + targetPageId: seed.targetPageIds[1], + observedSlotToken: observed.slotObservationToken!, + route: { + kind: "forward-property" as const, + propertyId: seed.propertyId, + sourcePageId: seed.sourcePageId, + }, + }, + ], + }; + const committed = await asViewer(() => mutate.run(input)); + const displacedEdgeId = committed.results[0]!.displacedEdgeIds![0]!; + await dbModule + .getDb() + .delete(dbModule.schema.documentShares) + .where( + and( + eq(dbModule.schema.documentShares.resourceId, seed.targetPageIds[0]), + eq(dbModule.schema.documentShares.principalType, "user"), + eq(dbModule.schema.documentShares.principalId, viewer), + ), + ); + + let replayError: unknown; + try { + await asViewer(() => mutate.run(input)); + } catch (error) { + replayError = error; + } + expect(replayError).toMatchObject({ + errorCode: "NOT_ACCESSIBLE", + message: "The requested Content object is not accessible.", + }); + const serializedError = JSON.stringify(replayError); + expect(serializedError).not.toContain(displacedEdgeId); + expect(serializedError).not.toContain(seed.targetPageIds[0]); + }); + + it("records trusted agent lineage separately from its authorizing principal", async () => { + const seed = await relationshipFixture(); + const forgedOperationId = `${seed.prefix}-forged-attribution`; + await expect( + asOwner(() => + mutate.run({ + ...addInput(seed, forgedOperationId), + actor: { kind: "person", displayName: "Forged actor" }, + authorizingPrincipal: { + kind: "user", + email: "forged@example.test", + }, + } as never), + ), + ).rejects.toThrow(); + + const operationId = `${seed.prefix}-trusted-attribution`; + const result = await mutate.run(addInput(seed, operationId), { + caller: "tool", + userEmail: owner, + networkProtocol: "a2a", + networkId: "network-relationship-test", + networkPeer: "external-relationship-agent", + threadId: "thread-relationship-test", + turnId: "turn-relationship-test", + runId: "run-relationship-test", + }); + const [revision] = await dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipRevisions) + .where( + eq(dbModule.schema.contentRelationshipRevisions.id, result.revisionId), + ); + expect(JSON.parse(revision!.actorJson)).toEqual({ + kind: "agent", + displayName: "external-relationship-agent", + runId: "run-relationship-test", + networkProtocol: "a2a", + networkId: "network-relationship-test", + networkPeer: "external-relationship-agent", + threadId: "thread-relationship-test", + turnId: "turn-relationship-test", + }); + expect(JSON.parse(revision!.authorizingPrincipalJson)).toEqual({ + kind: "user", + email: owner, + orgId: null, + }); + expect(revision).toMatchObject({ + origin: "tool", + operationId, + }); + const forgedReceipts = await dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipReceipts) + .where( + eq( + dbModule.schema.contentRelationshipReceipts.operationId, + forgedOperationId, + ), + ); + expect(forgedReceipts).toEqual([]); + }); + + it("rolls back state, Revision, and receipt when Event insertion fails", async () => { + const seed = await relationshipFixture(); + const operationId = `${seed.prefix}-forced-event-failure`; + const baselineEvents = await dbModule + .getDb() + .select({ id: dbModule.schema.contentRelationshipEvents.id }) + .from(dbModule.schema.contentRelationshipEvents) + .where( + eq( + dbModule.schema.contentRelationshipEvents.relationshipTypeId, + seed.typeId, + ), + ); + await dbModule + .getDb() + .execute( + sql.raw( + "alter table content_relationship_events add constraint relationship_services_event_failure check (kind <> 'relationship-added') not valid", + ), + ); + try { + await expect( + asOwner(() => mutate.run(addInput(seed, operationId))), + ).rejects.toThrow(); + } finally { + await dbModule + .getDb() + .execute( + sql.raw( + "alter table content_relationship_events drop constraint relationship_services_event_failure", + ), + ); + } + + const [lineages, revisions, receipts, events] = await Promise.all([ + dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipLineages) + .where( + and( + eq( + dbModule.schema.contentRelationshipLineages.relationshipTypeId, + seed.typeId, + ), + eq( + dbModule.schema.contentRelationshipLineages.sourcePageId, + seed.sourcePageId, + ), + ), + ), + dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipRevisions) + .where( + eq( + dbModule.schema.contentRelationshipRevisions.operationId, + operationId, + ), + ), + dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipReceipts) + .where( + eq( + dbModule.schema.contentRelationshipReceipts.operationId, + operationId, + ), + ), + dbModule + .getDb() + .select({ id: dbModule.schema.contentRelationshipEvents.id }) + .from(dbModule.schema.contentRelationshipEvents) + .where( + eq( + dbModule.schema.contentRelationshipEvents.relationshipTypeId, + seed.typeId, + ), + ), + ]); + expect(lineages).toEqual([]); + expect(revisions).toEqual([]); + expect(receipts).toEqual([]); + expect(events).toEqual(baselineEvents); + }); + + it("returns the original receipt on retry without appending duplicate events", async () => { + const seed = await relationshipFixture(); + const input = addInput(seed, `${seed.prefix}-lost-response`); + const committed = await asOwner(() => mutate.run(input)); + const beforeEvents = await dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipEvents) + .where( + eq( + dbModule.schema.contentRelationshipEvents.revisionId, + committed.revisionId, + ), + ); + + const retried = await asOwner(() => mutate.run(input)); + const afterEvents = await dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipEvents) + .where( + eq( + dbModule.schema.contentRelationshipEvents.revisionId, + committed.revisionId, + ), + ); + expect(retried).toEqual(committed); + expect(afterEvents).toEqual(beforeEvents); + const receipts = await dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipReceipts) + .where( + eq( + dbModule.schema.contentRelationshipReceipts.operationId, + input.operationId, + ), + ); + expect(receipts).toHaveLength(1); + }); + + it("rolls back state, Event, Revision, and receipt when receipt insertion fails", async () => { + const seed = await relationshipFixture(); + const operationId = `${seed.prefix}-forced-receipt-failure`; + const baselineEvents = await dbModule + .getDb() + .select({ id: dbModule.schema.contentRelationshipEvents.id }) + .from(dbModule.schema.contentRelationshipEvents) + .where( + eq( + dbModule.schema.contentRelationshipEvents.relationshipTypeId, + seed.typeId, + ), + ); + await dbModule + .getDb() + .execute( + sql.raw( + `alter table content_relationship_receipts add constraint relationship_services_receipt_failure check (operation_id <> '${operationId}')`, + ), + ); + try { + await expect( + asOwner(() => mutate.run(addInput(seed, operationId))), + ).rejects.toThrow(); + } finally { + await dbModule + .getDb() + .execute( + sql.raw( + "alter table content_relationship_receipts drop constraint relationship_services_receipt_failure", + ), + ); + } + + const [lineages, revisions, receipts, events] = await Promise.all([ + dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipLineages) + .where( + and( + eq( + dbModule.schema.contentRelationshipLineages.relationshipTypeId, + seed.typeId, + ), + eq( + dbModule.schema.contentRelationshipLineages.sourcePageId, + seed.sourcePageId, + ), + ), + ), + dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipRevisions) + .where( + eq( + dbModule.schema.contentRelationshipRevisions.operationId, + operationId, + ), + ), + dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipReceipts) + .where( + eq( + dbModule.schema.contentRelationshipReceipts.operationId, + operationId, + ), + ), + dbModule + .getDb() + .select({ id: dbModule.schema.contentRelationshipEvents.id }) + .from(dbModule.schema.contentRelationshipEvents) + .where( + eq( + dbModule.schema.contentRelationshipEvents.relationshipTypeId, + seed.typeId, + ), + ), + ]); + expect(lineages).toEqual([]); + expect(revisions).toEqual([]); + expect(receipts).toEqual([]); + expect(events).toEqual(baselineEvents); + }); +}); diff --git a/templates/content/actions/relationship-undo.db.test.ts b/templates/content/actions/relationship-undo.db.test.ts new file mode 100644 index 00000000000..359e74532e9 --- /dev/null +++ b/templates/content/actions/relationship-undo.db.test.ts @@ -0,0 +1,749 @@ +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runWithRequestContext } from "@agent-native/core/server"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const databasePath = join( + tmpdir(), + `relationship-undo-${process.pid}-${Date.now()}.pglite`, +); +const owner = "relationship-undo-owner@example.test"; +const viewer = "relationship-undo-viewer@example.test"; +const spaceId = `relationship-undo-${process.pid}-${Date.now()}`; + +let dbModule: typeof import("../server/db/index.js"); +let configure: typeof import("./configure-content-relation-property.js").default; +let listRelationships: typeof import("./list-content-relationships.js").default; +let listHistory: typeof import("./list-content-relationship-history.js").default; +let mutate: typeof import("./mutate-content-relationships.js").default; +let prepareRemoval: typeof import("./prepare-content-relationship-removal.js").default; +let removeProperty: typeof import("./remove-content-relation-property.js").default; +let undo: typeof import("./undo-content-relationship-revision.js").default; +let sequence = 0; + +const asUser = (userEmail: string, run: () => Promise) => + runWithRequestContext({ userEmail }, run); +const asOwner = (run: () => Promise) => asUser(owner, run); +const asViewer = (run: () => Promise) => asUser(viewer, run); + +beforeAll(async () => { + process.env.DATABASE_URL = `pglite:${databasePath}`; + dbModule = await import("../server/db/index.js"); + await (await import("../server/plugins/db.js")).default(undefined as never); + configure = (await import("./configure-content-relation-property.js")) + .default; + listRelationships = (await import("./list-content-relationships.js")).default; + listHistory = (await import("./list-content-relationship-history.js")) + .default; + mutate = (await import("./mutate-content-relationships.js")).default; + prepareRemoval = (await import("./prepare-content-relationship-removal.js")) + .default; + removeProperty = (await import("./remove-content-relation-property.js")) + .default; + undo = (await import("./undo-content-relationship-revision.js")).default; + + const filesDatabaseId = `${spaceId}-files`; + await dbModule.getDb().insert(dbModule.schema.contentSpaces).values({ + id: spaceId, + name: "Relationship undo", + kind: "personal", + ownerEmail: owner, + filesDatabaseId, + createdBy: owner, + }); + await dbModule + .getDb() + .insert(dbModule.schema.documents) + .values({ + id: `${filesDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Files", + }); + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabases) + .values({ + id: filesDatabaseId, + documentId: `${filesDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Files", + systemRole: "files", + blocksSeeded: 1, + }); +}); + +afterAll(() => { + delete process.env.DATABASE_URL; + rmSync(databasePath, { recursive: true, force: true }); +}); + +async function fixture(cardinality: "one" | "many" = "many") { + const prefix = `${spaceId}-${++sequence}`; + const sourceDatabaseId = `${prefix}-deliverables`; + const targetDatabaseId = `${prefix}-people`; + const sourcePageId = `${prefix}-launch`; + const targetPageIds = [`${prefix}-mira`, `${prefix}-jo`]; + await dbModule + .getDb() + .insert(dbModule.schema.documents) + .values([ + { + id: `${sourceDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Campaign deliverables", + }, + { + id: `${targetDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Marketing team", + }, + { + id: sourcePageId, + spaceId, + ownerEmail: owner, + title: "Launch article", + }, + { + id: targetPageIds[0], + spaceId, + ownerEmail: owner, + title: "Mira", + }, + { + id: targetPageIds[1], + spaceId, + ownerEmail: owner, + title: "Jo", + }, + ]); + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabases) + .values([ + { + id: sourceDatabaseId, + spaceId, + ownerEmail: owner, + documentId: `${sourceDatabaseId}-page`, + title: "Deliverables", + blocksSeeded: 1, + }, + { + id: targetDatabaseId, + spaceId, + ownerEmail: owner, + documentId: `${targetDatabaseId}-page`, + title: "People", + blocksSeeded: 1, + }, + ]); + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabaseItems) + .values([ + { + id: `${prefix}-source-item`, + databaseId: sourceDatabaseId, + documentId: sourcePageId, + ownerEmail: owner, + }, + ...targetPageIds.map((documentId, index) => ({ + id: `${prefix}-target-item-${index}`, + databaseId: targetDatabaseId, + documentId, + ownerEmail: owner, + position: index, + })), + ]); + const configured = await asOwner(() => + configure.run({ + ownerDatabaseId: sourceDatabaseId, + alias: cardinality === "one" ? "Assignee" : "Contributors", + operationId: `${prefix}-configure`, + definition: { + kind: "new-local", + forwardLabel: "Contributes to", + inverseLabel: "Deliverables", + forwardCardinality: cardinality, + sourceDatabaseId, + targetDatabaseId, + }, + inverseProjection: { + ownerDatabaseId: targetDatabaseId, + alias: "Deliverables", + editable: true, + }, + }), + ); + return { + prefix, + sourceDatabaseId, + targetDatabaseId, + sourcePageId, + targetPageIds, + typeId: configured.relationshipType.id, + typeVersionId: configured.relationshipTypeVersion.id, + propertyId: configured.projection.propertyId, + }; +} + +type Fixture = Awaited>; + +function addInput(seed: Fixture, operationId: string, targetPageId: string) { + return { + operationId, + changes: [ + { + kind: "add" as const, + typeId: seed.typeId, + typeVersionId: seed.typeVersionId, + sourcePageId: seed.sourcePageId, + targetPageId, + route: { + kind: "forward-property" as const, + propertyId: seed.propertyId, + sourcePageId: seed.sourcePageId, + }, + }, + ], + }; +} + +async function add(seed: Fixture, operationId: string, targetPageId: string) { + return asOwner(() => mutate.run(addInput(seed, operationId, targetPageId))); +} + +async function outgoing(seed: Fixture) { + return asOwner(() => + listRelationships.run({ + pageId: seed.sourcePageId, + relationshipTypeId: seed.typeId, + direction: "outgoing", + }), + ); +} + +async function markDatabaseDeleted(databaseId: string) { + const deletedAt = new Date().toISOString(); + await dbModule + .getDb() + .update(dbModule.schema.contentDatabases) + .set({ deletedAt, updatedAt: deletedAt }) + .where(eq(dbModule.schema.contentDatabases.id, databaseId)); +} + +describe("typed relationship Undo", () => { + it("retires only the activation added by the recovered revision", async () => { + const seed = await fixture(); + const first = await add( + seed, + `${seed.prefix}-first-add`, + seed.targetPageIds[0], + ); + const concurrent = await add( + seed, + `${seed.prefix}-concurrent-add`, + seed.targetPageIds[0], + ); + const input = { + revisionId: first.revisionId, + recoveryToken: ( + await asOwner(() => listHistory.run({ revisionId: first.revisionId })) + ).items[0]!.recovery.recoveryToken!, + operationId: `${seed.prefix}-undo-first-add`, + routes: [], + }; + const recovered = await asOwner(() => undo.run(input)); + const replayed = await asOwner(() => undo.run(input)); + expect(replayed).toEqual(recovered); + const current = await outgoing(seed); + expect(current.items).toHaveLength(1); + expect(current.items[0]!.observedActivationIds).toEqual( + concurrent.results[0]!.activationIds, + ); + await expect( + asOwner(() => + undo.run({ + ...input, + operationId: `${seed.prefix}-second-undo`, + }), + ), + ).rejects.toMatchObject({ errorCode: "STALE_RECOVERY" }); + }); + + it("restores a removal without overwriting a later assertion", async () => { + const seed = await fixture(); + await add(seed, `${seed.prefix}-add`, seed.targetPageIds[0]); + const observed = (await outgoing(seed)).items[0]!; + const removed = await asOwner(() => + mutate.run({ + operationId: `${seed.prefix}-remove`, + changes: [ + { + kind: "remove", + edgeId: observed.edgeId, + observedActivationIds: observed.observedActivationIds, + observationToken: observed.observationToken, + route: observed.routes[0]!, + }, + ], + }), + ); + const concurrent = await add( + seed, + `${seed.prefix}-later-add`, + seed.targetPageIds[0], + ); + const history = await asOwner(() => + listHistory.run({ revisionId: removed.revisionId }), + ); + const recovered = await asOwner(() => + undo.run({ + revisionId: removed.revisionId, + recoveryToken: history.items[0]!.recovery.recoveryToken!, + operationId: `${seed.prefix}-undo-remove`, + routes: [], + }), + ); + const current = (await outgoing(seed)).items[0]!; + expect(current.observedActivationIds).toEqual( + [ + ...concurrent.results[0]!.activationIds, + ...recovered.results[0]!.activationIds, + ].sort(), + ); + }); + + it("restores an immediate max-one replacement and rejects stale recovery", async () => { + const immediate = await fixture("one"); + const first = await add( + immediate, + `${immediate.prefix}-add`, + immediate.targetPageIds[0], + ); + const initial = (await outgoing(immediate)).items[0]!; + const replacement = await asOwner(() => + mutate.run({ + operationId: `${immediate.prefix}-replace`, + changes: [ + { + kind: "replace", + typeId: immediate.typeId, + typeVersionId: immediate.typeVersionId, + sourcePageId: immediate.sourcePageId, + targetPageId: immediate.targetPageIds[1], + observedSlotToken: initial.slotObservationToken!, + route: initial.routes[0]!, + }, + ], + }), + ); + const history = await asOwner(() => + listHistory.run({ revisionId: replacement.revisionId }), + ); + expect(history.items[0]?.changes).toEqual([ + expect.objectContaining({ + kind: "replaced", + relationshipLabel: "Contributes to", + source: { + pageId: immediate.sourcePageId, + title: "Launch article", + }, + target: { pageId: immediate.targetPageIds[1], title: "Jo" }, + previousTarget: { + pageId: immediate.targetPageIds[0], + title: "Mira", + }, + }), + ]); + const previousTargetHistory = await asOwner(() => + listHistory.run({ pageId: immediate.targetPageIds[0] }), + ); + expect( + previousTargetHistory.items.find( + (item) => item.revisionId === replacement.revisionId, + )?.changes, + ).toEqual([ + expect.objectContaining({ + kind: "replaced", + relationshipLabel: "Contributes to", + source: { + pageId: immediate.sourcePageId, + title: "Launch article", + }, + target: { pageId: immediate.targetPageIds[1], title: "Jo" }, + previousTarget: { + pageId: immediate.targetPageIds[0], + title: "Mira", + }, + }), + ]); + const restored = await asOwner(() => + undo.run({ + revisionId: replacement.revisionId, + recoveryToken: history.items[0]!.recovery.recoveryToken!, + operationId: `${immediate.prefix}-undo-replace`, + routes: [], + }), + ); + const restoredHistory = await asOwner(() => + listHistory.run({ revisionId: restored.revisionId }), + ); + expect(restoredHistory.items[0]?.changes).toEqual([ + expect.objectContaining({ + kind: "restored", + relationshipLabel: "Contributes to", + source: { + pageId: immediate.sourcePageId, + title: "Launch article", + }, + target: { pageId: immediate.targetPageIds[0], title: "Mira" }, + previousTarget: { + pageId: immediate.targetPageIds[1], + title: "Jo", + }, + }), + ]); + expect((await outgoing(immediate)).items[0]!.edgeId).toBe( + first.results[0]!.edgeId, + ); + + const stale = await fixture("one"); + await add(stale, `${stale.prefix}-add`, stale.targetPageIds[0]); + const staleInitial = (await outgoing(stale)).items[0]!; + const staleReplacement = await asOwner(() => + mutate.run({ + operationId: `${stale.prefix}-replace`, + changes: [ + { + kind: "replace", + typeId: stale.typeId, + typeVersionId: stale.typeVersionId, + sourcePageId: stale.sourcePageId, + targetPageId: stale.targetPageIds[1], + observedSlotToken: staleInitial.slotObservationToken!, + route: staleInitial.routes[0]!, + }, + ], + }), + ); + const later = (await outgoing(stale)).items[0]!; + await asOwner(() => + mutate.run({ + operationId: `${stale.prefix}-later-replace`, + changes: [ + { + kind: "replace", + typeId: stale.typeId, + typeVersionId: stale.typeVersionId, + sourcePageId: stale.sourcePageId, + targetPageId: stale.targetPageIds[0], + observedSlotToken: later.slotObservationToken!, + route: later.routes[0]!, + }, + ], + }), + ); + const staleHistory = await asOwner(() => + listHistory.run({ revisionId: staleReplacement.revisionId }), + ); + await expect( + asOwner(() => + undo.run({ + revisionId: staleReplacement.revisionId, + recoveryToken: staleHistory.items[0]!.recovery.recoveryToken!, + operationId: `${stale.prefix}-stale-undo`, + routes: [], + }), + ), + ).rejects.toMatchObject({ errorCode: "STALE_RECOVERY" }); + }); + + it("restores the same Property identity and selected relationship", async () => { + const seed = await fixture(); + const added = await add(seed, `${seed.prefix}-add`, seed.targetPageIds[0]); + const prepared = await asOwner(() => + prepareRemoval.run({ + selection: { kind: "property", propertyId: seed.propertyId }, + }), + ); + const removed = await asOwner(() => + removeProperty.run({ + propertyId: seed.propertyId, + relationshipMode: { + kind: "remove-selected", + selectionReceipt: prepared.selectionReceipt, + }, + operationId: `${seed.prefix}-remove-property`, + }), + ); + const recovered = await asOwner(() => + undo.run({ + revisionId: removed.revisionId, + recoveryToken: removed.undo.recoveryToken, + operationId: `${seed.prefix}-undo-property`, + routes: [], + }), + ); + const [definition] = await dbModule + .getDb() + .select() + .from(dbModule.schema.documentPropertyDefinitions) + .where( + eq(dbModule.schema.documentPropertyDefinitions.id, seed.propertyId), + ); + expect(definition?.id).toBe(seed.propertyId); + const current = (await outgoing(seed)).items[0]!; + expect(current.edgeId).toBe(added.results[0]!.edgeId); + expect(current.observedActivationIds).toEqual( + recovered.results[0]!.activationIds, + ); + }); + + it("preserves relation column presentation and later unrelated view edits", async () => { + const seed = await fixture(); + const relationPresentation = { + activeViewId: "table", + views: [ + { + id: "table", + name: "Table", + type: "table", + sorts: [], + filters: [], + columnWidths: { [seed.propertyId]: 328 }, + tableColumnOrderIds: ["name", seed.propertyId], + columnWrapOverrides: { [seed.propertyId]: true }, + frozenThroughColumnId: seed.propertyId, + }, + ], + sorts: [], + filters: [], + columnWidths: {}, + }; + await dbModule + .getDb() + .update(dbModule.schema.contentDatabases) + .set({ viewConfigJson: JSON.stringify(relationPresentation) }) + .where(eq(dbModule.schema.contentDatabases.id, seed.sourceDatabaseId)); + const removed = await asOwner(() => + removeProperty.run({ + propertyId: seed.propertyId, + relationshipMode: { kind: "keep" }, + operationId: `${seed.prefix}-remove-presented-property`, + }), + ); + const editedPresentation = { + ...relationPresentation, + views: [{ ...relationPresentation.views[0]!, rowDensity: "compact" }], + }; + await dbModule + .getDb() + .update(dbModule.schema.contentDatabases) + .set({ viewConfigJson: JSON.stringify(editedPresentation) }) + .where(eq(dbModule.schema.contentDatabases.id, seed.sourceDatabaseId)); + + await asOwner(() => + undo.run({ + revisionId: removed.revisionId, + recoveryToken: removed.undo.recoveryToken, + operationId: `${seed.prefix}-undo-presented-property`, + routes: [], + }), + ); + const [database] = await dbModule + .getDb() + .select({ + viewConfigJson: dbModule.schema.contentDatabases.viewConfigJson, + }) + .from(dbModule.schema.contentDatabases) + .where(eq(dbModule.schema.contentDatabases.id, seed.sourceDatabaseId)); + expect(JSON.parse(database!.viewConfigJson)).toEqual(editedPresentation); + }); + + it("allows removal compensation but blocks recovery that needs deleted selectors", async () => { + const addedSeed = await fixture(); + const added = await add( + addedSeed, + `${addedSeed.prefix}-add`, + addedSeed.targetPageIds[0], + ); + const addedHistory = await asOwner(() => + listHistory.run({ revisionId: added.revisionId }), + ); + await markDatabaseDeleted(addedSeed.targetDatabaseId); + await expect( + asOwner(() => + undo.run({ + revisionId: added.revisionId, + recoveryToken: addedHistory.items[0]!.recovery.recoveryToken!, + operationId: `${addedSeed.prefix}-undo-add-after-selector-delete`, + routes: [ + { + kind: "connections-forward", + sourcePageId: addedSeed.sourcePageId, + }, + ], + }), + ), + ).resolves.toMatchObject({ undoneRevisionId: added.revisionId }); + + const removedSeed = await fixture(); + await add( + removedSeed, + `${removedSeed.prefix}-add`, + removedSeed.targetPageIds[0], + ); + const observed = (await outgoing(removedSeed)).items[0]!; + const removed = await asOwner(() => + mutate.run({ + operationId: `${removedSeed.prefix}-remove`, + changes: [ + { + kind: "remove", + edgeId: observed.edgeId, + observedActivationIds: observed.observedActivationIds, + observationToken: observed.observationToken, + route: observed.routes[0]!, + }, + ], + }), + ); + const removedHistory = await asOwner(() => + listHistory.run({ revisionId: removed.revisionId }), + ); + await markDatabaseDeleted(removedSeed.targetDatabaseId); + await expect( + asOwner(() => + undo.run({ + revisionId: removed.revisionId, + recoveryToken: removedHistory.items[0]!.recovery.recoveryToken!, + operationId: `${removedSeed.prefix}-undo-remove-after-selector-delete`, + routes: [ + { + kind: "connections-forward", + sourcePageId: removedSeed.sourcePageId, + }, + ], + }), + ), + ).rejects.toMatchObject({ errorCode: "CONSTRAINT_UNAVAILABLE" }); + + const replacedSeed = await fixture("one"); + await add( + replacedSeed, + `${replacedSeed.prefix}-add`, + replacedSeed.targetPageIds[0], + ); + const beforeReplace = (await outgoing(replacedSeed)).items[0]!; + const replaced = await asOwner(() => + mutate.run({ + operationId: `${replacedSeed.prefix}-replace`, + changes: [ + { + kind: "replace", + typeId: replacedSeed.typeId, + typeVersionId: replacedSeed.typeVersionId, + sourcePageId: replacedSeed.sourcePageId, + targetPageId: replacedSeed.targetPageIds[1], + observedSlotToken: beforeReplace.slotObservationToken!, + route: beforeReplace.routes[0]!, + }, + ], + }), + ); + const replacedHistory = await asOwner(() => + listHistory.run({ revisionId: replaced.revisionId }), + ); + await markDatabaseDeleted(replacedSeed.targetDatabaseId); + await expect( + asOwner(() => + undo.run({ + revisionId: replaced.revisionId, + recoveryToken: replacedHistory.items[0]!.recovery.recoveryToken!, + operationId: `${replacedSeed.prefix}-undo-replace-after-selector-delete`, + routes: [ + { + kind: "connections-forward", + sourcePageId: replacedSeed.sourcePageId, + }, + ], + }), + ), + ).rejects.toMatchObject({ errorCode: "CONSTRAINT_UNAVAILABLE" }); + + const propertySeed = await fixture(); + const propertyRemoved = await asOwner(() => + removeProperty.run({ + propertyId: propertySeed.propertyId, + relationshipMode: { kind: "keep" }, + operationId: `${propertySeed.prefix}-remove-property`, + }), + ); + await markDatabaseDeleted(propertySeed.targetDatabaseId); + await expect( + asOwner(() => + undo.run({ + revisionId: propertyRemoved.revisionId, + recoveryToken: propertyRemoved.undo.recoveryToken, + operationId: `${propertySeed.prefix}-undo-property-after-selector-delete`, + routes: [], + }), + ), + ).rejects.toMatchObject({ errorCode: "CONSTRAINT_UNAVAILABLE" }); + }); + + it("rechecks endpoint access before recovery", async () => { + const seed = await fixture(); + const documents = [ + `${seed.sourceDatabaseId}-page`, + `${seed.targetDatabaseId}-page`, + seed.sourcePageId, + ...seed.targetPageIds, + ]; + await dbModule + .getDb() + .insert(dbModule.schema.documentShares) + .values( + documents.map((resourceId, index) => ({ + id: `${seed.prefix}-share-${index}`, + resourceId, + principalType: "user", + principalId: viewer, + role: "editor", + createdBy: owner, + })), + ); + const added = await asViewer(() => + mutate.run( + addInput(seed, `${seed.prefix}-viewer-add`, seed.targetPageIds[0]), + ), + ); + const history = await asViewer(() => + listHistory.run({ revisionId: added.revisionId }), + ); + await dbModule + .getDb() + .delete(dbModule.schema.documentShares) + .where( + eq(dbModule.schema.documentShares.resourceId, seed.targetPageIds[0]), + ); + await expect( + asViewer(() => + undo.run({ + revisionId: added.revisionId, + recoveryToken: history.items[0]!.recovery.recoveryToken!, + operationId: `${seed.prefix}-denied-undo`, + routes: [], + }), + ), + ).rejects.toMatchObject({ errorCode: "NOT_ACCESSIBLE" }); + }); +}); diff --git a/templates/content/actions/remove-content-relation-property.ts b/templates/content/actions/remove-content-relation-property.ts new file mode 100644 index 00000000000..da813da6546 --- /dev/null +++ b/templates/content/actions/remove-content-relation-property.ts @@ -0,0 +1,530 @@ +import { defineAction, type ActionRunContext } from "@agent-native/core/action"; +import { and, eq, inArray, sql } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { + removeContentRelationPropertyInputSchema, + type RelationshipInvalidation, + type RemoveContentRelationPropertyInput, + type RemoveContentRelationPropertyResult, +} from "../shared/relationships.js"; +import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; +import { nanoid } from "./_property-utils.js"; +import { authorizeRelationshipRoute } from "./_relationship-authority.js"; +import { + activeActivationIdsForLineages, + appendRelationshipEvent, + createRelationshipRevision, + insertRelationshipReceipt, + loadRelationshipDatabase, + loadRelationshipTypeBundle, + lockRelationshipLineages, + lockRelationshipOperation, + lockRelationshipTypes, + relationshipActorContext, + relationshipError, + relationshipProjectionDto, + relationshipRequestHash, + requireRelationshipDocumentAccess, + replayRelationshipReceipt, + retireRelationshipActivations, + type RelationshipDb, +} from "./_relationship-core.js"; +import { + relationshipRemovalSelectionEntrySchema, + type RelationshipRemovalSelectionEntry, +} from "./prepare-content-relationship-removal.js"; + +function parseRemovalSelection( + value: string, +): RelationshipRemovalSelectionEntry[] { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + relationshipError( + "UNAVAILABLE", + "The relationship removal selection is unreadable.", + { statusCode: 503 }, + ); + } + const result = relationshipRemovalSelectionEntrySchema + .array() + .safeParse(parsed); + if (!result.success) { + relationshipError( + "UNAVAILABLE", + "The relationship removal selection is invalid.", + { statusCode: 503 }, + ); + } + return result.data; +} + +function selectRemovalEntries( + entries: RelationshipRemovalSelectionEntry[], + edgeIds: string[] | undefined, +): RelationshipRemovalSelectionEntry[] { + if (!edgeIds) return entries; + const requestedIds = new Set(edgeIds); + const entriesById = new Map(entries.map((entry) => [entry.edgeId, entry])); + if ( + requestedIds.size !== edgeIds.length || + edgeIds.some((edgeId) => !entriesById.has(edgeId)) + ) { + relationshipError( + "STALE_SELECTION", + "The relationship removal selection is stale.", + { statusCode: 409 }, + ); + } + return entries.filter((entry) => requestedIds.has(entry.edgeId)); +} + +async function loadProjectionAndDefinition( + db: RelationshipDb, + propertyId: string, +) { + const [projection] = await db + .select() + .from(schema.contentRelationshipProjections) + .where(eq(schema.contentRelationshipProjections.propertyId, propertyId)); + if (!projection) { + relationshipError( + "NOT_ACCESSIBLE", + "The requested relation Property is not accessible.", + { statusCode: 404 }, + ); + } + const [definition] = await db + .select() + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.id, propertyId)); + return { projection, definition: definition ?? null }; +} + +async function preflightSelection( + db: RelationshipDb, + entries: RelationshipRemovalSelectionEntry[], + context?: ActionRunContext, +) { + for (const entry of entries) { + const bundle = await loadRelationshipTypeBundle(entry.typeId, { + allowArchived: true, + db, + }); + if (bundle.version.id !== entry.typeVersionId) { + relationshipError( + "STALE_SELECTION", + "The selected relationship definition changed.", + { statusCode: 409 }, + ); + } + await authorizeRelationshipRoute({ + db, + bundle, + sourcePageId: entry.sourcePageId, + targetPageId: entry.targetPageId, + route: entry.route, + operation: "remove", + context, + }); + } +} + +async function assertRemovalReceiptAccessible( + db: RelationshipDb, + entries: RelationshipRemovalSelectionEntry[], + context?: ActionRunContext, +): Promise { + const bundles = new Map< + string, + Awaited> + >(); + for (const entry of entries) { + let bundle = bundles.get(entry.typeId); + if (!bundle) { + bundle = await loadRelationshipTypeBundle(entry.typeId, { + allowArchived: true, + db, + }); + await Promise.all([ + loadRelationshipDatabase( + bundle.version.sourceDatabaseId, + "viewer", + db, + context, + { allowDeleted: true }, + ), + loadRelationshipDatabase( + bundle.version.targetDatabaseId, + "viewer", + db, + context, + { allowDeleted: true }, + ), + ]); + bundles.set(entry.typeId, bundle); + } + await Promise.all([ + requireRelationshipDocumentAccess(entry.sourcePageId, "viewer", { + db, + context, + }), + requireRelationshipDocumentAccess(entry.targetPageId, "viewer", { + db, + context, + }), + ]); + } +} + +async function removeContentRelationProperty( + input: RemoveContentRelationPropertyInput, + context?: ActionRunContext, +): Promise { + const db = getDb(); + const initial = await loadProjectionAndDefinition(db, input.propertyId); + const database = await loadRelationshipDatabase( + initial.projection.databaseId, + "admin", + db, + context, + ); + const actor = relationshipActorContext(context); + let initialSelection: + | typeof schema.contentRelationshipRemovalSelections.$inferSelect + | null = null; + let entries: RelationshipRemovalSelectionEntry[] = []; + if (input.relationshipMode.kind === "remove-selected") { + [initialSelection] = await db + .select() + .from(schema.contentRelationshipRemovalSelections) + .where( + eq( + schema.contentRelationshipRemovalSelections.token, + input.relationshipMode.selectionReceipt, + ), + ); + if ( + !initialSelection || + initialSelection.callerScope !== actor.callerScope || + initialSelection.propertyId !== input.propertyId + ) { + relationshipError( + "STALE_SELECTION", + "The relationship removal selection does not match this Property.", + { statusCode: 409 }, + ); + } + entries = selectRemovalEntries( + parseRemovalSelection(initialSelection.selectionJson), + input.relationshipMode.edgeIds, + ); + } + const requestHash = relationshipRequestHash(input); + const tenant = { + ownerEmail: initial.projection.ownerEmail, + orgId: initial.projection.orgId, + spaceId: initial.projection.spaceId, + }; + + return db.transaction(async (rawTx) => { + const tx = rawTx as unknown as RelationshipDb; + await lockRelationshipOperation(tx, { + tenant, + operationId: input.operationId, + context, + }); + await lockContentDatabaseMutation(tx, database.database.id); + const lockedDatabase = await loadRelationshipDatabase( + database.database.id, + "admin", + tx, + context, + ); + const replayed = + await replayRelationshipReceipt(tx, { + spaceId: tenant.spaceId, + operationId: input.operationId, + requestHash, + context, + }); + if (replayed) { + await assertRemovalReceiptAccessible(tx, entries, context); + return replayed; + } + + const current = await loadProjectionAndDefinition(tx, input.propertyId); + if (current.projection.archivedAt || !current.definition) { + relationshipError( + "STALE_RECOVERY", + "The relation Property has already been removed.", + { statusCode: 409 }, + ); + } + await lockRelationshipTypes(tx, [current.projection.relationshipTypeId]); + await lockRelationshipLineages( + tx, + entries.map((entry) => entry.edgeId), + ); + let selection: + | typeof schema.contentRelationshipRemovalSelections.$inferSelect + | null = null; + if (input.relationshipMode.kind === "remove-selected") { + await tx + .update(schema.contentRelationshipRemovalSelections) + .set({ + usedAt: sql`${schema.contentRelationshipRemovalSelections.usedAt}`, + }) + .where( + eq( + schema.contentRelationshipRemovalSelections.token, + input.relationshipMode.selectionReceipt, + ), + ) + .returning({ + token: schema.contentRelationshipRemovalSelections.token, + }); + [selection] = await tx + .select() + .from(schema.contentRelationshipRemovalSelections) + .where( + eq( + schema.contentRelationshipRemovalSelections.token, + input.relationshipMode.selectionReceipt, + ), + ); + if ( + !selection || + selection.callerScope !== actor.callerScope || + selection.propertyId !== input.propertyId || + selection.usedAt || + selection.expiresAt <= new Date().toISOString() + ) { + relationshipError( + "STALE_SELECTION", + "The relationship removal selection is stale.", + { statusCode: 409 }, + ); + } + entries = selectRemovalEntries( + parseRemovalSelection(selection.selectionJson), + input.relationshipMode.edgeIds, + ); + await preflightSelection(tx, entries, context); + const activeByLineage = await activeActivationIdsForLineages( + tx, + entries.map((entry) => entry.edgeId), + ); + for (const entry of entries) { + const active = new Set(activeByLineage.get(entry.edgeId) ?? []); + if ( + entry.observedActivationIds.some( + (activationId) => !active.has(activationId), + ) + ) { + relationshipError( + "STALE_SELECTION", + "A selected relationship changed before removal.", + { statusCode: 409 }, + ); + } + } + } + + const lockedBundle = await loadRelationshipTypeBundle( + current.projection.relationshipTypeId, + { allowArchived: true, db: tx }, + ); + const [mappedField] = await tx + .select({ id: schema.contentDatabaseSourceFields.id }) + .from(schema.contentDatabaseSourceFields) + .where( + eq(schema.contentDatabaseSourceFields.propertyId, input.propertyId), + ); + if (mappedField) { + relationshipError( + "SOURCE_AUTHORITY_UNSUPPORTED", + "Source-mapped relation Property removal is not supported.", + { statusCode: 409 }, + ); + } + if (lockedDatabase.database.naturalKeyPropertyId === input.propertyId) { + relationshipError( + "UNSUPPORTED_CONFIGURATION", + "A relation Property used as the Database natural key cannot be removed.", + { statusCode: 409 }, + ); + } + const oldDefinition = current.definition; + const oldProjection = current.projection; + const revision = await createRelationshipRevision(tx, { + tenant, + operationId: input.operationId, + operation: "remove-relation-property", + diff: { + kind: "remove-relation-property", + propertyDefinition: oldDefinition, + projection: relationshipProjectionDto(oldProjection), + removedRelationships: entries, + }, + context, + }); + await appendRelationshipEvent(tx, revision, { + tenant, + kind: "relationship-projection-removed", + relationshipTypeId: lockedBundle.type.id, + relationshipTypeVersionId: lockedBundle.version.id, + targets: { + propertyId: oldProjection.propertyId, + databaseId: oldProjection.databaseId, + }, + diff: { + propertyDefinition: oldDefinition, + projection: relationshipProjectionDto(oldProjection), + }, + }); + const removedEdgeIds: string[] = []; + for (const entry of entries) { + const eventId = nanoid(24); + const retiredIds = await retireRelationshipActivations(tx, { + activationIds: entry.observedActivationIds, + eventId, + tenant, + actorEmail: actor.actor.displayName, + }); + await appendRelationshipEvent(tx, revision, { + tenant, + eventId, + kind: "relationship-removed-with-projection", + relationshipTypeId: entry.typeId, + relationshipTypeVersionId: entry.typeVersionId, + route: entry.route, + targets: { + lineageId: entry.edgeId, + sourcePageId: entry.sourcePageId, + targetPageId: entry.targetPageId, + }, + diff: { retiredActivationIds: retiredIds }, + }); + const remaining = await activeActivationIdsForLineages(tx, [ + entry.edgeId, + ]); + if ((remaining.get(entry.edgeId)?.length ?? 0) === 0) { + await tx + .update(schema.contentRelationshipCardinalitySlots) + .set({ + lineageId: null, + targetPageId: null, + updatedAt: new Date().toISOString(), + }) + .where( + and( + eq( + schema.contentRelationshipCardinalitySlots.relationshipTypeId, + entry.typeId, + ), + eq( + schema.contentRelationshipCardinalitySlots.sourcePageId, + entry.sourcePageId, + ), + eq( + schema.contentRelationshipCardinalitySlots.lineageId, + entry.edgeId, + ), + ), + ); + } + removedEdgeIds.push(entry.edgeId); + } + const now = new Date().toISOString(); + await tx + .update(schema.contentRelationshipProjections) + .set({ archivedAt: now, updatedAt: now }) + .where(eq(schema.contentRelationshipProjections.id, oldProjection.id)); + await tx + .delete(schema.documentPropertyValues) + .where(eq(schema.documentPropertyValues.propertyId, input.propertyId)); + await tx + .delete(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq( + schema.contentDatabaseItemKeyClaims.databaseId, + oldProjection.databaseId, + ), + eq(schema.contentDatabaseItemKeyClaims.propertyId, input.propertyId), + ), + ); + await tx + .delete(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.id, input.propertyId)); + const [databaseRow] = await tx + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, oldProjection.databaseId)); + if (!databaseRow) { + relationshipError( + "CONSTRAINT_UNAVAILABLE", + "The relation Property database became unavailable.", + { statusCode: 409 }, + ); + } + if (selection) { + await tx + .update(schema.contentRelationshipRemovalSelections) + .set({ usedAt: now }) + .where( + eq( + schema.contentRelationshipRemovalSelections.token, + selection.token, + ), + ); + } + const invalidation: RelationshipInvalidation = { + pageIds: [ + databaseRow.documentId, + ...entries.flatMap((entry) => [entry.sourcePageId, entry.targetPageId]), + ] + .filter((id, index, all) => all.indexOf(id) === index) + .sort(), + databaseIds: [oldProjection.databaseId], + propertyIds: [input.propertyId], + relationshipTypeIds: [oldProjection.relationshipTypeId], + }; + const receiptId = nanoid(24); + const result: RemoveContentRelationPropertyResult = { + operationId: input.operationId, + receiptId, + revisionId: revision.revisionId, + eventIds: revision.eventIds, + invalidation, + propertyId: input.propertyId, + relationshipTypeId: oldProjection.relationshipTypeId, + removedEdgeIds: [...new Set(removedEdgeIds)].sort(), + undo: { + revisionId: revision.revisionId, + recoveryToken: revision.recoveryToken, + }, + }; + await insertRelationshipReceipt(tx, { + id: receiptId, + tenant, + operationId: input.operationId, + requestHash, + revisionId: revision.revisionId, + result, + context, + }); + return result; + }); +} + +export default defineAction({ + description: + "Remove one canonical relation Property while preserving its type and, by default, its relationship edges; an exact prepared selection can retire observed edges atomically.", + mcpTool: true, + schema: removeContentRelationPropertyInputSchema, + run: removeContentRelationProperty, +}); diff --git a/templates/content/actions/restore-content-database.ts b/templates/content/actions/restore-content-database.ts index 75331730fbc..e8a4f23b862 100644 --- a/templates/content/actions/restore-content-database.ts +++ b/templates/content/actions/restore-content-database.ts @@ -39,7 +39,7 @@ export default defineAction({ schema: z.object({ databaseId: z.string().describe("Content database ID"), }), - run: async ({ databaseId }) => { + run: async ({ databaseId }, context) => { const ownership = await assertContentDatabaseLifecycleAccess(databaseId); const db = getDb(); const now = new Date().toISOString(); @@ -71,6 +71,7 @@ export default defineAction({ tx as unknown as ReturnType, ownership.database.documentId, ownership.database.ownerEmail, + context, ); if ( backingDocument.trashedAt && diff --git a/templates/content/actions/restore-document.ts b/templates/content/actions/restore-document.ts index ec0c8c2be4e..e80d77c5433 100644 --- a/templates/content/actions/restore-document.ts +++ b/templates/content/actions/restore-document.ts @@ -11,13 +11,14 @@ export default defineAction({ schema: z.object({ id: z.string().describe("Trashed root document ID"), }), - run: async ({ id }) => { + run: async ({ id }, context) => { const access = await assertAccess("document", id, "admin"); const restored = await getDb().transaction((tx) => restoreDocumentSubtree( tx as unknown as ReturnType, id, access.resource.ownerEmail as string, + context, ), ); if (restored.length === 0) throw new Error("Document is not in Trash"); diff --git a/templates/content/actions/set-document-property.ts b/templates/content/actions/set-document-property.ts index d358c061c1d..c992ceb3ac1 100644 --- a/templates/content/actions/set-document-property.ts +++ b/templates/content/actions/set-document-property.ts @@ -1,4 +1,4 @@ -import { defineAction } from "@agent-native/core/action"; +import { ActionContractError, defineAction } from "@agent-native/core/action"; import { assertAccess } from "@agent-native/core/sharing"; import { and, eq, isNull, ne } from "drizzle-orm"; import { z } from "zod"; @@ -26,6 +26,7 @@ import { nanoid, normalizedValueJson, } from "./_property-utils.js"; +import { assertCanonicalRelationPropertyValueWrite } from "./_relationship-compatibility.js"; export default defineAction({ description: "Set a Notion-style property value on a document.", @@ -88,6 +89,7 @@ export default defineAction({ ), ); if (!membership) throw new Error("Document is not part of this database."); + await assertCanonicalRelationPropertyValueWrite({ propertyId }); const type = definition.type as DocumentPropertyType; if (isComputedPropertyType(type)) { throw new Error("Computed properties cannot be edited."); @@ -275,6 +277,15 @@ export default defineAction({ if (!lockedMembership) { throw new Error("Document is not part of this database."); } + if ( + parsePropertyOptions(lockedDefinition.optionsJson).relation + ?.relationshipTypeId + ) { + throw new ActionContractError( + "Use mutate-content-relationships for canonical relationship values.", + { errorCode: "USE_RELATIONSHIP_MUTATION" }, + ); + } const lockedType = lockedDefinition.type as DocumentPropertyType; if (lockedType !== type) { throw new Error( diff --git a/templates/content/actions/stage-builder-source-bulk-update.ts b/templates/content/actions/stage-builder-source-bulk-update.ts index 0769fc4a024..eac1c565dd4 100644 --- a/templates/content/actions/stage-builder-source-bulk-update.ts +++ b/templates/content/actions/stage-builder-source-bulk-update.ts @@ -21,6 +21,7 @@ import { type DocumentPropertyType, } from "../shared/properties.js"; import { BUILDER_CMS_FIXTURE_ROW_PROVENANCE } from "./_builder-cms-source-adapter.js"; +import { assertNotCanonicalRelationProjection } from "./_canonical-relation-guard.js"; import { DATABASE_ROW_BATCH_LIMIT, resolveDatabaseRowsForBatch, @@ -339,6 +340,12 @@ async function stageBuilderSourceBulkUpdateWithDeps( if (field.propertyId && !definition) { throw new Error("Mapped database property not found."); } + if (definition) { + assertNotCanonicalRelationProjection( + definition, + "Builder bulk updates cannot write canonical relationship projections. Use the relationship actions instead.", + ); + } const propertyType = definition?.type as DocumentPropertyType | undefined; const propertyBlocker = propertyType && diff --git a/templates/content/actions/submit-content-database-form.db.test.ts b/templates/content/actions/submit-content-database-form.db.test.ts index 356d7be4f7d..ec9221791e8 100644 --- a/templates/content/actions/submit-content-database-form.db.test.ts +++ b/templates/content/actions/submit-content-database-form.db.test.ts @@ -181,6 +181,29 @@ async function seedFormDatabase() { } describe("submit-content-database-form", () => { + it("rejects enabled canonical relationship projections before creating a row", async () => { + const seeded = await seedFormDatabase(); + await getDb() + .update(schema.documentPropertyDefinitions) + .set({ + optionsJson: JSON.stringify({ + relation: { relationshipTypeId: "relationship-type" }, + }), + }) + .where(eq(schema.documentPropertyDefinitions.id, seeded.priorityId)); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + submitForm.run({ + databaseId: seeded.databaseId, + viewId: "request-form", + title: "Relationship form attempt", + propertyValues: { Priority: "P1 — High" }, + }), + ), + ).rejects.toMatchObject({ errorCode: "USE_RELATIONSHIP_MUTATION" }); + }); + it("atomically writes title, primary/additional Blocks, safe options, and an exact link", async () => { const seeded = await seedFormDatabase(); const result = await runWithRequestContext({ userEmail: OWNER }, () => diff --git a/templates/content/actions/submit-content-database-form.ts b/templates/content/actions/submit-content-database-form.ts index 8656836707d..314e15d6a36 100644 --- a/templates/content/actions/submit-content-database-form.ts +++ b/templates/content/actions/submit-content-database-form.ts @@ -25,6 +25,7 @@ import { type DocumentPropertyType, type DocumentPropertyValue, } from "../shared/properties.js"; +import { assertNotCanonicalRelationProjection } from "./_canonical-relation-guard.js"; import { lockContentDatabaseMutation, touchContentDatabase, @@ -283,6 +284,13 @@ export default defineAction({ .filter((question) => question.key !== "name") .map((question) => question.key), ); + for (const definition of definitions) { + if (!enabledPropertyIds.has(definition.id)) continue; + assertNotCanonicalRelationProjection( + definition, + "Forms cannot submit canonical relationship projections. Create the row, then use the relationship actions instead.", + ); + } const values = resolveSubmittedProperties( definitions, enabledPropertyIds, @@ -363,6 +371,13 @@ export default defineAction({ ), ), ); + for (const definition of lockedDefinitions) { + if (!enabledPropertyIds.has(definition.id)) continue; + assertNotCanonicalRelationProjection( + definition, + "Forms cannot submit canonical relationship projections. Create the row, then use the relationship actions instead.", + ); + } if ( propertyDefinitionFingerprint(lockedDefinitions) !== definitionsFingerprint diff --git a/templates/content/actions/undo-content-relationship-revision.ts b/templates/content/actions/undo-content-relationship-revision.ts new file mode 100644 index 00000000000..513a7c64d1d --- /dev/null +++ b/templates/content/actions/undo-content-relationship-revision.ts @@ -0,0 +1,1238 @@ +import { + defineAction, + isActionContractError, + type ActionRunContext, +} from "@agent-native/core/action"; +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import { + canonicalRelationProjectionSchema, + relationshipRouteRefSchema, + undoContentRelationshipRevisionInputSchema, + type RelationshipInvalidation, + type RelationshipMutationResultItem, + type RelationshipRouteRef, + type UndoContentRelationshipRevisionInput, + type UndoContentRelationshipRevisionResult, +} from "../shared/relationships.js"; +import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; +import { nanoid } from "./_property-utils.js"; +import { authorizeRelationshipRoute } from "./_relationship-authority.js"; +import { + activeActivationIdsForLineages, + appendRelationshipEvent, + createRelationshipRevision, + insertRelationshipReceipt, + loadRelationshipDatabase, + loadRelationshipTypeBundle, + lockRelationshipCardinalitySlots, + lockRelationshipLineages, + lockRelationshipOperation, + lockRelationshipTypes, + mergeRelationshipInvalidation, + relationshipActorContext, + relationshipError, + relationshipRequestHash, + replayRelationshipReceipt, + requireRelationshipDocumentAccess, + retireRelationshipActivations, + type RelationshipDb, + type RelationshipTypeBundle, +} from "./_relationship-core.js"; +import { relationshipRemovalSelectionEntrySchema } from "./prepare-content-relationship-removal.js"; + +const eventTargetsSchema = z + .object({ + lineageId: z.string().min(1), + sourcePageId: z.string().min(1), + targetPageId: z.string().min(1), + displacedLineageIds: z.array(z.string().min(1)).optional(), + }) + .strict(); +const eventDiffSchema = z + .object({ + addedActivationIds: z.array(z.string().min(1)).optional(), + retiredActivationIds: z.array(z.string().min(1)).optional(), + }) + .strict(); +const propertyDefinitionSnapshotSchema = z + .object({ + id: z.string().min(1), + ownerEmail: z.string().min(1), + orgId: z.string().nullable(), + databaseId: z.string().nullable(), + systemRole: z.string().nullable(), + name: z.string(), + type: z.string(), + description: z.string(), + visibility: z.string(), + optionsJson: z.string(), + position: z.number().int(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .strict(); +const propertyRemovalDiffSchema = z + .object({ + kind: z.literal("remove-relation-property"), + propertyDefinition: propertyDefinitionSnapshotSchema, + projection: canonicalRelationProjectionSchema, + removedRelationships: z.array(relationshipRemovalSelectionEntrySchema), + }) + .strict(); + +type MutationUndoPlan = { + originalKind: "add" | "remove" | "replace"; + event: typeof schema.contentRelationshipEvents.$inferSelect; + lineage: typeof schema.contentRelationshipLineages.$inferSelect; + displacedLineages: Array< + typeof schema.contentRelationshipLineages.$inferSelect + >; + bundle: RelationshipTypeBundle; + originalRoute: RelationshipRouteRef; + route: RelationshipRouteRef; + displacedRoutes: Map; + addedActivationIds: string[]; + retiredActivationIds: string[]; +}; + +function parseJson(value: string, description: string): unknown { + try { + return JSON.parse(value); + } catch { + relationshipError("UNAVAILABLE", `${description} is unreadable.`, { + statusCode: 503, + }); + } +} + +function routeMatchesEndpoints( + route: RelationshipRouteRef, + sourcePageId: string, + targetPageId: string, +): boolean { + return route.kind === "inverse-property" + ? route.targetPageId === targetPageId + : route.sourcePageId === sourcePageId; +} + +async function resolveUndoRoute( + db: RelationshipDb, + args: { + bundle: RelationshipTypeBundle; + sourcePageId: string; + targetPageId: string; + originalRoute: RelationshipRouteRef; + suppliedRoutes: RelationshipRouteRef[]; + context?: ActionRunContext; + }, +): Promise { + const candidates = [...args.suppliedRoutes, args.originalRoute].filter( + (route, index, all) => + routeMatchesEndpoints(route, args.sourcePageId, args.targetPageId) && + all.findIndex( + (candidate) => JSON.stringify(candidate) === JSON.stringify(route), + ) === index, + ); + let accessError: unknown; + for (const route of candidates) { + try { + await authorizeRelationshipRoute({ + db, + bundle: args.bundle, + sourcePageId: args.sourcePageId, + targetPageId: args.targetPageId, + route, + operation: "undo", + context: args.context, + }); + return route; + } catch (error) { + if ( + !isActionContractError(error) || + ![ + "NOT_ACCESSIBLE", + "ROUTE_NOT_AUTHORIZED", + "SOURCE_AUTHORITY_UNSUPPORTED", + ].includes(error.errorCode) + ) { + throw error; + } + if (error.errorCode === "NOT_ACCESSIBLE") { + accessError ??= error; + } + } + } + if (accessError) { + throw accessError; + } + relationshipError( + "ROUTE_NOT_AUTHORIZED", + "No currently authorized route can recover this relationship change.", + { statusCode: 403 }, + ); +} + +async function mutationUndoPlans( + db: RelationshipDb, + events: Array, + suppliedRoutes: RelationshipRouteRef[], + context?: ActionRunContext, +): Promise { + const plans: MutationUndoPlan[] = []; + for (const event of events) { + const kind = + event.kind === "relationship-added" + ? "add" + : event.kind === "relationship-removed" + ? "remove" + : event.kind === "relationship-replaced" + ? "replace" + : null; + if (!kind || !event.relationshipTypeId) { + relationshipError( + "UNSUPPORTED_CONFIGURATION", + "This relationship revision cannot be recovered by this Action.", + ); + } + const targets = eventTargetsSchema.safeParse( + parseJson(event.targetsJson, "A relationship event target"), + ); + const diff = eventDiffSchema.safeParse( + parseJson(event.diffJson, "A relationship event diff"), + ); + const originalRoute = relationshipRouteRefSchema.safeParse( + parseJson(event.routeJson, "A relationship event route"), + ); + if (!targets.success || !diff.success || !originalRoute.success) { + relationshipError( + "UNAVAILABLE", + "A relationship event cannot be recovered safely.", + { statusCode: 503 }, + ); + } + const [lineage] = await db + .select() + .from(schema.contentRelationshipLineages) + .where(eq(schema.contentRelationshipLineages.id, targets.data.lineageId)); + if ( + !lineage || + lineage.relationshipTypeId !== event.relationshipTypeId || + lineage.sourcePageId !== targets.data.sourcePageId || + lineage.targetPageId !== targets.data.targetPageId + ) { + relationshipError( + "UNAVAILABLE", + "A relationship lineage needed for recovery is unavailable.", + { statusCode: 503 }, + ); + } + const displacedIds = targets.data.displacedLineageIds ?? []; + const displacedLineages = displacedIds.length + ? await db + .select() + .from(schema.contentRelationshipLineages) + .where(inArray(schema.contentRelationshipLineages.id, displacedIds)) + : []; + if ( + displacedLineages.length !== new Set(displacedIds).size || + displacedLineages.some( + (displaced) => + displaced.relationshipTypeId !== lineage.relationshipTypeId || + displaced.sourcePageId !== lineage.sourcePageId, + ) + ) { + relationshipError( + "UNAVAILABLE", + "A displaced relationship needed for recovery is unavailable.", + { statusCode: 503 }, + ); + } + const bundle = await loadRelationshipTypeBundle(event.relationshipTypeId, { + allowArchived: true, + db, + }); + const selectorDatabases = await Promise.all([ + loadRelationshipDatabase( + bundle.version.sourceDatabaseId, + "viewer", + db, + context, + { allowDeleted: true }, + ), + loadRelationshipDatabase( + bundle.version.targetDatabaseId, + "viewer", + db, + context, + { allowDeleted: true }, + ), + ]); + if ( + (kind === "remove" || + (kind === "replace" && displacedLineages.length > 0)) && + selectorDatabases.some(({ database }) => database.deletedAt) + ) { + relationshipError( + "CONSTRAINT_UNAVAILABLE", + "A relationship admission database is unavailable.", + { statusCode: 409 }, + ); + } + const route = await resolveUndoRoute(db, { + bundle, + sourcePageId: lineage.sourcePageId, + targetPageId: lineage.targetPageId, + originalRoute: originalRoute.data, + suppliedRoutes, + context, + }); + const displacedRoutes = new Map(); + for (const displaced of displacedLineages) { + displacedRoutes.set( + displaced.id, + await resolveUndoRoute(db, { + bundle, + sourcePageId: displaced.sourcePageId, + targetPageId: displaced.targetPageId, + originalRoute: originalRoute.data, + suppliedRoutes, + context, + }), + ); + } + plans.push({ + originalKind: kind, + event, + lineage, + displacedLineages, + bundle, + originalRoute: originalRoute.data, + route, + displacedRoutes, + addedActivationIds: [ + ...new Set(diff.data.addedActivationIds ?? []), + ].sort(), + retiredActivationIds: [ + ...new Set(diff.data.retiredActivationIds ?? []), + ].sort(), + }); + } + return plans; +} + +async function assertPropertyRemovalAccess( + db: RelationshipDb, + snapshot: z.infer, + context?: ActionRunContext, +): Promise { + const selectorDatabases = await Promise.all([ + loadRelationshipDatabase( + snapshot.projection.databaseId, + "admin", + db, + context, + { allowDeleted: true }, + ), + ]); + const bundle = await loadRelationshipTypeBundle( + snapshot.projection.relationshipTypeId, + { allowArchived: true, db }, + ); + selectorDatabases.push( + ...(await Promise.all([ + loadRelationshipDatabase( + bundle.version.sourceDatabaseId, + "viewer", + db, + context, + { allowDeleted: true }, + ), + loadRelationshipDatabase( + bundle.version.targetDatabaseId, + "viewer", + db, + context, + { allowDeleted: true }, + ), + ])), + ); + if (selectorDatabases.some(({ database }) => database.deletedAt)) { + relationshipError( + "CONSTRAINT_UNAVAILABLE", + "A relationship admission database is unavailable.", + { statusCode: 409 }, + ); + } + for (const entry of snapshot.removedRelationships) { + await Promise.all([ + requireRelationshipDocumentAccess( + entry.sourcePageId, + snapshot.projection.direction === "inverse" ? "viewer" : "editor", + { db, context }, + ), + requireRelationshipDocumentAccess( + entry.targetPageId, + snapshot.projection.direction === "inverse" ? "editor" : "viewer", + { db, context }, + ), + ]); + } +} + +async function activeLineagesForSlot( + db: RelationshipDb, + typeId: string, + sourcePageId: string, +) { + const lineages = await db + .select() + .from(schema.contentRelationshipLineages) + .where( + and( + eq(schema.contentRelationshipLineages.relationshipTypeId, typeId), + eq(schema.contentRelationshipLineages.sourcePageId, sourcePageId), + ), + ); + const active = await activeActivationIdsForLineages( + db, + lineages.map((lineage) => lineage.id), + ); + return { + lineages: lineages.filter( + (lineage) => (active.get(lineage.id)?.length ?? 0) > 0, + ), + active, + }; +} + +async function addRecoveryActivation( + tx: RelationshipDb, + args: { + lineageId: string; + eventId: string; + tenant: { ownerEmail: string; orgId: string | null; spaceId: string }; + actorName: string; + }, +): Promise { + const activationId = nanoid(24); + await tx.insert(schema.contentRelationshipActivations).values({ + id: activationId, + ownerEmail: args.tenant.ownerEmail, + orgId: args.tenant.orgId, + spaceId: args.tenant.spaceId, + lineageId: args.lineageId, + addedEventId: args.eventId, + createdBy: args.actorName, + }); + return activationId; +} + +async function updateSlot( + tx: RelationshipDb, + args: { + typeId: string; + sourcePageId: string; + lineageId: string | null; + targetPageId: string | null; + }, +): Promise { + await tx + .update(schema.contentRelationshipCardinalitySlots) + .set({ + lineageId: args.lineageId, + targetPageId: args.targetPageId, + updatedAt: new Date().toISOString(), + }) + .where( + and( + eq( + schema.contentRelationshipCardinalitySlots.relationshipTypeId, + args.typeId, + ), + eq( + schema.contentRelationshipCardinalitySlots.sourcePageId, + args.sourcePageId, + ), + ), + ); +} + +function emptyInvalidation(): RelationshipInvalidation { + return { + pageIds: [], + databaseIds: [], + propertyIds: [], + relationshipTypeIds: [], + }; +} + +async function assertPlanActivationHistory( + db: RelationshipDb, + plan: MutationUndoPlan, +): Promise { + const activationIds = [ + ...new Set([...plan.addedActivationIds, ...plan.retiredActivationIds]), + ]; + if (activationIds.length === 0) return; + const rows = await db + .select({ + id: schema.contentRelationshipActivations.id, + lineageId: schema.contentRelationshipActivations.lineageId, + addedEventId: schema.contentRelationshipActivations.addedEventId, + removedEventId: + schema.contentRelationshipActivationRetirements.removedEventId, + }) + .from(schema.contentRelationshipActivations) + .leftJoin( + schema.contentRelationshipActivationRetirements, + eq( + schema.contentRelationshipActivationRetirements.activationId, + schema.contentRelationshipActivations.id, + ), + ) + .where(inArray(schema.contentRelationshipActivations.id, activationIds)); + if (rows.length !== activationIds.length) { + relationshipError( + "UNAVAILABLE", + "The relationship activation history is incomplete.", + { statusCode: 503 }, + ); + } + const displacedIds = new Set( + plan.displacedLineages.map((lineage) => lineage.id), + ); + for (const row of rows) { + if (plan.addedActivationIds.includes(row.id)) { + if ( + row.lineageId !== plan.lineage.id || + row.addedEventId !== plan.event.id + ) { + relationshipError( + "UNAVAILABLE", + "The relationship add history is inconsistent.", + { statusCode: 503 }, + ); + } + } else if ( + row.removedEventId !== plan.event.id || + (plan.originalKind === "replace" + ? !displacedIds.has(row.lineageId) + : row.lineageId !== plan.lineage.id) + ) { + relationshipError( + "UNAVAILABLE", + "The relationship removal history is inconsistent.", + { statusCode: 503 }, + ); + } + } +} + +async function undoContentRelationshipRevision( + input: UndoContentRelationshipRevisionInput, + context?: ActionRunContext, +): Promise { + const db = getDb(); + const [originalRevision] = await db + .select() + .from(schema.contentRelationshipRevisions) + .where(eq(schema.contentRelationshipRevisions.id, input.revisionId)); + if ( + !originalRevision || + originalRevision.recoveryToken !== input.recoveryToken + ) { + relationshipError( + "STALE_RECOVERY", + "The relationship recovery reference is stale.", + { statusCode: 409 }, + ); + } + const originalEvents = await db + .select() + .from(schema.contentRelationshipEvents) + .where(eq(schema.contentRelationshipEvents.revisionId, input.revisionId)) + .orderBy(schema.contentRelationshipEvents.sequence); + if (originalEvents.length === 0) { + relationshipError( + "UNAVAILABLE", + "The relationship revision has no committed Events.", + { statusCode: 503 }, + ); + } + let initialMutationPlans: MutationUndoPlan[] = []; + let initialPropertySnapshot: z.infer< + typeof propertyRemovalDiffSchema + > | null = null; + if (originalRevision.operation === "mutate-relationships") { + initialMutationPlans = await mutationUndoPlans( + db, + originalEvents, + input.routes, + context, + ); + } else if (originalRevision.operation === "remove-relation-property") { + const snapshot = propertyRemovalDiffSchema.safeParse( + parseJson( + originalRevision.diffJson, + "The relationship Property recovery snapshot", + ), + ); + if (!snapshot.success) { + relationshipError( + "UNAVAILABLE", + "The relationship Property recovery snapshot is invalid.", + { statusCode: 503 }, + ); + } + initialPropertySnapshot = snapshot.data; + await assertPropertyRemovalAccess(db, snapshot.data, context); + } else { + relationshipError( + "UNSUPPORTED_CONFIGURATION", + "This relationship revision cannot be undone.", + ); + } + const tenant = { + ownerEmail: originalRevision.ownerEmail, + orgId: originalRevision.orgId, + spaceId: originalRevision.spaceId, + }; + const requestHash = relationshipRequestHash(input); + const actor = relationshipActorContext(context); + + return db.transaction(async (rawTx) => { + const tx = rawTx as unknown as RelationshipDb; + await lockRelationshipOperation(tx, { + tenant, + operationId: input.operationId, + context, + }); + const replayed = + await replayRelationshipReceipt( + tx, + { + spaceId: tenant.spaceId, + operationId: input.operationId, + requestHash, + context, + }, + ); + if (replayed) return replayed; + await tx + .update(schema.contentRelationshipRevisions) + .set({ + recoveryToken: sql`${schema.contentRelationshipRevisions.recoveryToken}`, + }) + .where(eq(schema.contentRelationshipRevisions.id, input.revisionId)) + .returning({ id: schema.contentRelationshipRevisions.id }); + const [lockedOriginal] = await tx + .select() + .from(schema.contentRelationshipRevisions) + .where(eq(schema.contentRelationshipRevisions.id, input.revisionId)); + if ( + !lockedOriginal || + lockedOriginal.recoveryToken !== input.recoveryToken + ) { + relationshipError( + "STALE_RECOVERY", + "The relationship recovery reference is stale.", + { statusCode: 409 }, + ); + } + const [existingCompensation] = await tx + .select({ id: schema.contentRelationshipRevisions.id }) + .from(schema.contentRelationshipRevisions) + .where( + eq( + schema.contentRelationshipRevisions.compensatesRevisionId, + input.revisionId, + ), + ); + if (existingCompensation) { + relationshipError( + "STALE_RECOVERY", + "This relationship revision has already been recovered.", + { statusCode: 409 }, + ); + } + + const databaseIds = new Set(); + const typeIds = new Set(); + const lineageIds = new Set(); + const slotInputs: Parameters[1] = + []; + for (const plan of initialMutationPlans) { + databaseIds.add(plan.bundle.version.sourceDatabaseId); + databaseIds.add(plan.bundle.version.targetDatabaseId); + typeIds.add(plan.bundle.type.id); + lineageIds.add(plan.lineage.id); + for (const displaced of plan.displacedLineages) + lineageIds.add(displaced.id); + if (plan.bundle.version.forwardCardinality === "one") { + slotInputs.push({ + ownerEmail: plan.bundle.type.ownerEmail, + orgId: plan.bundle.type.orgId, + spaceId: plan.bundle.type.spaceId, + relationshipTypeId: plan.bundle.type.id, + sourcePageId: plan.lineage.sourcePageId, + }); + } + } + if (initialPropertySnapshot) { + databaseIds.add(initialPropertySnapshot.projection.databaseId); + typeIds.add(initialPropertySnapshot.projection.relationshipTypeId); + for (const entry of initialPropertySnapshot.removedRelationships) { + lineageIds.add(entry.edgeId); + const bundle = await loadRelationshipTypeBundle(entry.typeId, { + allowArchived: true, + db: tx, + }); + databaseIds.add(bundle.version.sourceDatabaseId); + databaseIds.add(bundle.version.targetDatabaseId); + if (bundle.version.forwardCardinality === "one") { + slotInputs.push({ + ownerEmail: bundle.type.ownerEmail, + orgId: bundle.type.orgId, + spaceId: bundle.type.spaceId, + relationshipTypeId: bundle.type.id, + sourcePageId: entry.sourcePageId, + }); + } + } + } + for (const databaseId of [...databaseIds].sort()) { + await lockContentDatabaseMutation(tx, databaseId); + } + await lockRelationshipTypes(tx, [...typeIds]); + await lockRelationshipLineages(tx, [...lineageIds]); + await lockRelationshipCardinalitySlots(tx, slotInputs); + + let mutationPlans: MutationUndoPlan[] = []; + let propertySnapshot = initialPropertySnapshot; + if (lockedOriginal.operation === "mutate-relationships") { + const events = await tx + .select() + .from(schema.contentRelationshipEvents) + .where( + eq(schema.contentRelationshipEvents.revisionId, input.revisionId), + ) + .orderBy(schema.contentRelationshipEvents.sequence); + mutationPlans = await mutationUndoPlans( + tx, + events, + input.routes, + context, + ); + } else if (lockedOriginal.operation === "remove-relation-property") { + const parsed = propertyRemovalDiffSchema.safeParse( + parseJson( + lockedOriginal.diffJson, + "The relationship Property recovery snapshot", + ), + ); + if (!parsed.success) { + relationshipError( + "UNAVAILABLE", + "The relationship Property recovery snapshot is invalid.", + { statusCode: 503 }, + ); + } + propertySnapshot = parsed.data; + await assertPropertyRemovalAccess(tx, parsed.data, context); + } + + const revision = await createRelationshipRevision(tx, { + tenant, + operationId: input.operationId, + operation: "undo-relationship-revision", + diff: { + kind: "undo-relationship-revision", + undoneRevisionId: input.revisionId, + }, + context, + compensatesRevisionId: input.revisionId, + }); + const invalidation = emptyInvalidation(); + const results: RelationshipMutationResultItem[] = []; + for (const plan of mutationPlans) { + await assertPlanActivationHistory(tx, plan); + } + for (const plan of [...mutationPlans].reverse()) { + mergeRelationshipInvalidation(invalidation, { + pageIds: [plan.lineage.sourcePageId, plan.lineage.targetPageId], + databaseIds: [ + plan.bundle.version.sourceDatabaseId, + plan.bundle.version.targetDatabaseId, + ], + relationshipTypeIds: [plan.bundle.type.id], + }); + if (plan.originalKind === "add") { + const eventId = nanoid(24); + const retiredIds = await retireRelationshipActivations(tx, { + activationIds: plan.addedActivationIds, + eventId, + tenant, + actorEmail: actor.actor.displayName, + }); + await appendRelationshipEvent(tx, revision, { + tenant, + eventId, + kind: "relationship-add-undone", + relationshipTypeId: plan.bundle.type.id, + relationshipTypeVersionId: plan.bundle.version.id, + route: plan.route, + targets: { + lineageId: plan.lineage.id, + sourcePageId: plan.lineage.sourcePageId, + targetPageId: plan.lineage.targetPageId, + }, + diff: { retiredActivationIds: retiredIds }, + }); + const remaining = await activeActivationIdsForLineages(tx, [ + plan.lineage.id, + ]); + const isActive = (remaining.get(plan.lineage.id)?.length ?? 0) > 0; + if (plan.bundle.version.forwardCardinality === "one" && !isActive) { + await updateSlot(tx, { + typeId: plan.bundle.type.id, + sourcePageId: plan.lineage.sourcePageId, + lineageId: null, + targetPageId: null, + }); + } + results.push({ + kind: "remove", + edgeId: plan.lineage.id, + lineageId: plan.lineage.id, + state: isActive ? "active" : "inactive", + activationIds: retiredIds, + }); + continue; + } + + if (plan.originalKind === "remove") { + if (plan.bundle.version.forwardCardinality === "one") { + const slot = await activeLineagesForSlot( + tx, + plan.bundle.type.id, + plan.lineage.sourcePageId, + ); + if (slot.lineages.some((lineage) => lineage.id !== plan.lineage.id)) { + relationshipError( + "STALE_RECOVERY", + "The max-one relationship changed after this revision.", + { statusCode: 409 }, + ); + } + } + const eventId = nanoid(24); + const activationIds: string[] = []; + if (plan.retiredActivationIds.length > 0) { + activationIds.push( + await addRecoveryActivation(tx, { + lineageId: plan.lineage.id, + eventId, + tenant, + actorName: actor.actor.displayName, + }), + ); + } + await appendRelationshipEvent(tx, revision, { + tenant, + eventId, + kind: "relationship-removal-undone", + relationshipTypeId: plan.bundle.type.id, + relationshipTypeVersionId: plan.bundle.version.id, + route: plan.route, + targets: { + lineageId: plan.lineage.id, + sourcePageId: plan.lineage.sourcePageId, + targetPageId: plan.lineage.targetPageId, + }, + diff: { addedActivationIds: activationIds }, + }); + if ( + activationIds.length > 0 && + plan.bundle.version.forwardCardinality === "one" + ) { + await updateSlot(tx, { + typeId: plan.bundle.type.id, + sourcePageId: plan.lineage.sourcePageId, + lineageId: plan.lineage.id, + targetPageId: plan.lineage.targetPageId, + }); + } + const current = await activeActivationIdsForLineages(tx, [ + plan.lineage.id, + ]); + results.push({ + kind: "add", + edgeId: plan.lineage.id, + lineageId: plan.lineage.id, + state: + (current.get(plan.lineage.id)?.length ?? 0) > 0 + ? "active" + : "inactive", + activationIds, + }); + continue; + } + + if ( + plan.bundle.version.forwardCardinality !== "one" || + plan.addedActivationIds.length !== 1 || + plan.displacedLineages.length > 1 + ) { + relationshipError( + "UNAVAILABLE", + "The replacement recovery history is inconsistent.", + { statusCode: 503 }, + ); + } + const slot = await activeLineagesForSlot( + tx, + plan.bundle.type.id, + plan.lineage.sourcePageId, + ); + const activeIds = [ + ...new Set( + slot.lineages.flatMap((lineage) => slot.active.get(lineage.id) ?? []), + ), + ].sort(); + if ( + slot.lineages.length !== 1 || + slot.lineages[0]?.id !== plan.lineage.id || + JSON.stringify(activeIds) !== JSON.stringify(plan.addedActivationIds) + ) { + relationshipError( + "STALE_RECOVERY", + "The max-one relationship changed after this replacement.", + { statusCode: 409 }, + ); + } + const eventId = nanoid(24); + const retiredIds = await retireRelationshipActivations(tx, { + activationIds: plan.addedActivationIds, + eventId, + tenant, + actorEmail: actor.actor.displayName, + }); + const restored = plan.displacedLineages[0] ?? null; + const activationIds = restored + ? [ + await addRecoveryActivation(tx, { + lineageId: restored.id, + eventId, + tenant, + actorName: actor.actor.displayName, + }), + ] + : []; + await appendRelationshipEvent(tx, revision, { + tenant, + eventId, + kind: "relationship-replacement-undone", + relationshipTypeId: plan.bundle.type.id, + relationshipTypeVersionId: plan.bundle.version.id, + route: restored + ? (plan.displacedRoutes.get(restored.id) ?? plan.route) + : plan.route, + targets: { + lineageId: restored?.id ?? plan.lineage.id, + sourcePageId: plan.lineage.sourcePageId, + targetPageId: restored?.targetPageId ?? plan.lineage.targetPageId, + displacedLineageIds: [plan.lineage.id], + }, + diff: { + addedActivationIds: activationIds, + retiredActivationIds: retiredIds, + }, + }); + await updateSlot(tx, { + typeId: plan.bundle.type.id, + sourcePageId: plan.lineage.sourcePageId, + lineageId: restored?.id ?? null, + targetPageId: restored?.targetPageId ?? null, + }); + results.push({ + kind: restored ? "replace" : "remove", + edgeId: restored?.id ?? plan.lineage.id, + lineageId: restored?.id ?? plan.lineage.id, + state: restored ? "active" : "inactive", + activationIds: restored ? activationIds : retiredIds, + displacedEdgeIds: [plan.lineage.id], + }); + } + + if (propertySnapshot) { + const [projectionRow] = await tx + .select() + .from(schema.contentRelationshipProjections) + .where( + eq( + schema.contentRelationshipProjections.propertyId, + propertySnapshot.projection.propertyId, + ), + ); + const [conflictingDefinition] = await tx + .select({ id: schema.documentPropertyDefinitions.id }) + .from(schema.documentPropertyDefinitions) + .where( + eq( + schema.documentPropertyDefinitions.id, + propertySnapshot.propertyDefinition.id, + ), + ); + if ( + !projectionRow || + !projectionRow.archivedAt || + conflictingDefinition || + projectionRow.id !== propertySnapshot.projection.id || + projectionRow.databaseId !== propertySnapshot.projection.databaseId || + projectionRow.relationshipTypeId !== + propertySnapshot.projection.relationshipTypeId || + propertySnapshot.propertyDefinition.databaseId !== + propertySnapshot.projection.databaseId || + propertySnapshot.propertyDefinition.type !== "relation" + ) { + relationshipError( + "STALE_RECOVERY", + "The relation Property identity is no longer available for recovery.", + { statusCode: 409 }, + ); + } + const selectedActivationIds = [ + ...new Set( + propertySnapshot.removedRelationships.flatMap( + (entry) => entry.observedActivationIds, + ), + ), + ]; + if (selectedActivationIds.length) { + const history = await tx + .select({ + activationId: schema.contentRelationshipActivations.id, + lineageId: schema.contentRelationshipActivations.lineageId, + revisionId: schema.contentRelationshipEvents.revisionId, + }) + .from(schema.contentRelationshipActivations) + .innerJoin( + schema.contentRelationshipActivationRetirements, + eq( + schema.contentRelationshipActivationRetirements.activationId, + schema.contentRelationshipActivations.id, + ), + ) + .innerJoin( + schema.contentRelationshipEvents, + eq( + schema.contentRelationshipEvents.id, + schema.contentRelationshipActivationRetirements.removedEventId, + ), + ) + .where( + inArray( + schema.contentRelationshipActivations.id, + selectedActivationIds, + ), + ); + const entryByActivation = new Map(); + for (const entry of propertySnapshot.removedRelationships) { + for (const activationId of entry.observedActivationIds) { + entryByActivation.set(activationId, entry.edgeId); + } + } + if ( + history.length !== selectedActivationIds.length || + history.some( + (row) => + row.revisionId !== input.revisionId || + entryByActivation.get(row.activationId) !== row.lineageId, + ) + ) { + relationshipError( + "UNAVAILABLE", + "The relation Property recovery history is incomplete.", + { statusCode: 503 }, + ); + } + } + for (const entry of propertySnapshot.removedRelationships) { + const bundle = await loadRelationshipTypeBundle(entry.typeId, { + allowArchived: true, + db: tx, + }); + if (bundle.version.forwardCardinality === "one") { + const slot = await activeLineagesForSlot( + tx, + bundle.type.id, + entry.sourcePageId, + ); + if (slot.lineages.some((lineage) => lineage.id !== entry.edgeId)) { + relationshipError( + "STALE_RECOVERY", + "A max-one relationship changed after the Property removal.", + { statusCode: 409 }, + ); + } + } + } + await tx + .insert(schema.documentPropertyDefinitions) + .values(propertySnapshot.propertyDefinition); + await tx + .update(schema.contentRelationshipProjections) + .set({ + alias: propertySnapshot.projection.alias, + description: propertySnapshot.projection.description, + editable: propertySnapshot.projection.editable ? 1 : 0, + archivedAt: null, + updatedAt: new Date().toISOString(), + }) + .where(eq(schema.contentRelationshipProjections.id, projectionRow.id)); + const propertyBundle = await loadRelationshipTypeBundle( + propertySnapshot.projection.relationshipTypeId, + { allowArchived: true, db: tx }, + ); + await appendRelationshipEvent(tx, revision, { + tenant, + kind: "relationship-projection-restored", + relationshipTypeId: propertyBundle.type.id, + relationshipTypeVersionId: propertyBundle.version.id, + targets: { + propertyId: propertySnapshot.projection.propertyId, + databaseId: propertySnapshot.projection.databaseId, + }, + diff: { + propertyDefinition: propertySnapshot.propertyDefinition, + projection: propertySnapshot.projection, + }, + }); + mergeRelationshipInvalidation(invalidation, { + databaseIds: [propertySnapshot.projection.databaseId], + propertyIds: [propertySnapshot.projection.propertyId], + relationshipTypeIds: [propertySnapshot.projection.relationshipTypeId], + }); + for (const entry of propertySnapshot.removedRelationships) { + const [lineage] = await tx + .select() + .from(schema.contentRelationshipLineages) + .where(eq(schema.contentRelationshipLineages.id, entry.edgeId)); + if ( + !lineage || + lineage.relationshipTypeId !== entry.typeId || + lineage.sourcePageId !== entry.sourcePageId || + lineage.targetPageId !== entry.targetPageId + ) { + relationshipError( + "UNAVAILABLE", + "A relationship lineage needed for Property recovery is unavailable.", + { statusCode: 503 }, + ); + } + const bundle = await loadRelationshipTypeBundle(entry.typeId, { + allowArchived: true, + db: tx, + }); + const route = await resolveUndoRoute(tx, { + bundle, + sourcePageId: entry.sourcePageId, + targetPageId: entry.targetPageId, + originalRoute: entry.route, + suppliedRoutes: input.routes, + context, + }); + const eventId = nanoid(24); + const activationId = await addRecoveryActivation(tx, { + lineageId: entry.edgeId, + eventId, + tenant, + actorName: actor.actor.displayName, + }); + await appendRelationshipEvent(tx, revision, { + tenant, + eventId, + kind: "relationship-removal-undone", + relationshipTypeId: bundle.type.id, + relationshipTypeVersionId: bundle.version.id, + route, + targets: { + lineageId: entry.edgeId, + sourcePageId: entry.sourcePageId, + targetPageId: entry.targetPageId, + }, + diff: { addedActivationIds: [activationId] }, + }); + if (bundle.version.forwardCardinality === "one") { + await updateSlot(tx, { + typeId: bundle.type.id, + sourcePageId: entry.sourcePageId, + lineageId: entry.edgeId, + targetPageId: entry.targetPageId, + }); + } + mergeRelationshipInvalidation(invalidation, { + pageIds: [entry.sourcePageId, entry.targetPageId], + databaseIds: [ + bundle.version.sourceDatabaseId, + bundle.version.targetDatabaseId, + ], + relationshipTypeIds: [bundle.type.id], + }); + results.push({ + kind: "add", + edgeId: entry.edgeId, + lineageId: entry.edgeId, + state: "active", + activationIds: [activationId], + }); + } + } + + await tx + .update(schema.contentRelationshipRevisions) + .set({ + diffJson: JSON.stringify({ + kind: "undo-relationship-revision", + undoneRevisionId: input.revisionId, + results, + }), + }) + .where(eq(schema.contentRelationshipRevisions.id, revision.revisionId)); + const receiptId = nanoid(24); + const result: UndoContentRelationshipRevisionResult = { + operationId: input.operationId, + receiptId, + revisionId: revision.revisionId, + eventIds: revision.eventIds, + invalidation, + undoneRevisionId: input.revisionId, + results, + undo: { + revisionId: revision.revisionId, + recoveryToken: revision.recoveryToken, + }, + }; + await insertRelationshipReceipt(tx, { + id: receiptId, + tenant, + operationId: input.operationId, + requestHash, + revisionId: revision.revisionId, + result, + context, + }); + return result; + }); +} + +export default defineAction({ + description: + "Safely compensate one committed canonical relationship mutation or relation Property removal using current authority and cardinality state.", + mcpTool: true, + schema: undoContentRelationshipRevisionInputSchema, + run: undoContentRelationshipRevision, +}); diff --git a/templates/content/actions/update-database-items.ts b/templates/content/actions/update-database-items.ts index a6d78403b72..b09c9254ba6 100644 --- a/templates/content/actions/update-database-items.ts +++ b/templates/content/actions/update-database-items.ts @@ -13,6 +13,7 @@ import { databaseRowBatchSchema, resolveDatabaseRowsForBatch, } from "./_database-row-batch.js"; +import { assertCanonicalRelationPropertyValueWrite } from "./_relationship-compatibility.js"; import setDocumentProperty from "./set-document-property.js"; const actionSchema = z.intersection( @@ -55,6 +56,10 @@ export default defineAction({ throw new Error("Computed properties cannot be edited."); } + await assertCanonicalRelationPropertyValueWrite({ + propertyId: args.propertyId, + }); + const results: Array<{ itemId: string; documentId: string; diff --git a/templates/content/actions/view-screen.test.ts b/templates/content/actions/view-screen.test.ts index e3b3eae9ce5..f7e78ee1b19 100644 --- a/templates/content/actions/view-screen.test.ts +++ b/templates/content/actions/view-screen.test.ts @@ -1,10 +1,17 @@ -import { describe, expect, it } from "vitest"; +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runWithRequestContext } from "@agent-native/core/server"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { ContentDatabaseResponse, DocumentProperty } from "../shared/api"; import { buildSelectionScreenSection, databaseCurrentViewSnapshot, documentContentPreview, + resolveRelationshipScreenContext, serializeDocumentTreeItemForScreen, SCREEN_DOCUMENT_PREVIEW_CHARS, } from "./view-screen"; @@ -68,6 +75,245 @@ describe("buildSelectionScreenSection", () => { }); }); +describe("view-screen relationship context", () => { + const databasePath = join( + tmpdir(), + `view-screen-relationships-${process.pid}-${Date.now()}.pglite`, + ); + const prefix = `view-screen-relationships-${process.pid}-${Date.now()}`; + const owner = `${prefix}-owner@example.test`; + const viewer = `${prefix}-viewer@example.test`; + const spaceId = `${prefix}-space`; + const sourceDatabaseId = `${prefix}-source-database`; + const targetDatabaseId = `${prefix}-target-database`; + const sourceDatabasePageId = `${prefix}-source-database-page`; + const targetDatabasePageId = `${prefix}-target-database-page`; + const pageId = `${prefix}-page`; + const typeId = `${prefix}-type`; + const typeVersionId = `${prefix}-type-version`; + const archivedTypeId = `${prefix}-archived-type`; + const unsupportedTypeId = `${prefix}-unsupported-type`; + const unsupportedTypeVersionId = `${prefix}-unsupported-type-version`; + const propertyId = `${prefix}-property`; + let dbModule: typeof import("../server/db/index.js"); + + beforeAll(async () => { + process.env.DATABASE_URL = `pglite:${databasePath}`; + dbModule = await import("../server/db/index.js"); + await (await import("../server/plugins/db.js")).default(undefined as never); + await dbModule + .getDb() + .insert(dbModule.schema.documents) + .values([ + { + id: sourceDatabasePageId, + spaceId, + ownerEmail: owner, + title: "Source database", + }, + { + id: targetDatabasePageId, + spaceId, + ownerEmail: owner, + title: "Target database", + }, + { + id: pageId, + spaceId, + ownerEmail: owner, + title: "Readable page", + }, + ]); + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabases) + .values([ + { + id: sourceDatabaseId, + documentId: sourceDatabasePageId, + spaceId, + ownerEmail: owner, + title: "Source database", + blocksSeeded: 1, + }, + { + id: targetDatabaseId, + documentId: targetDatabasePageId, + spaceId, + ownerEmail: owner, + title: "Target database", + blocksSeeded: 1, + }, + ]); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipTypes) + .values([ + { + id: typeId, + ownerEmail: owner, + spaceId, + currentVersionId: typeVersionId, + createdBy: owner, + }, + { + id: archivedTypeId, + ownerEmail: owner, + spaceId, + currentVersionId: `${prefix}-archived-type-version`, + state: "archived", + archivedAt: "2026-09-09T00:00:00.000Z", + createdBy: owner, + }, + { + id: unsupportedTypeId, + ownerEmail: owner, + spaceId, + currentVersionId: unsupportedTypeVersionId, + createdBy: owner, + }, + ]); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipTypeVersions) + .values([ + { + id: typeVersionId, + ownerEmail: owner, + spaceId, + relationshipTypeId: typeId, + version: 1, + forwardLabel: "References", + inverseLabel: "Referenced by", + forwardCardinality: "many", + sourceDatabaseId, + targetDatabaseId, + createdBy: owner, + }, + { + id: unsupportedTypeVersionId, + ownerEmail: owner, + spaceId, + relationshipTypeId: unsupportedTypeId, + version: 1, + forwardLabel: "Unsupported", + inverseLabel: "Unsupported by", + forwardCardinality: "many", + sourceDatabaseId, + targetDatabaseId, + selectorKind: "query", + createdBy: owner, + }, + ]); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipProjections) + .values({ + id: `${prefix}-projection`, + ownerEmail: owner, + spaceId, + propertyId, + databaseId: sourceDatabaseId, + relationshipTypeId: typeId, + direction: "forward", + editable: 1, + alias: "References", + createdBy: owner, + }); + }); + + afterAll(() => { + delete process.env.DATABASE_URL; + rmSync(databasePath, { recursive: true, force: true }); + }); + + it("returns database-only relationship configuration context", async () => { + await expect( + runWithRequestContext({ userEmail: owner }, () => + resolveRelationshipScreenContext({ + databaseId: sourceDatabaseId, + surface: "configuration", + }), + ), + ).resolves.toEqual({ + databaseId: sourceDatabaseId, + surface: "configuration", + selectedPageIds: undefined, + }); + }); + + it("makes hidden-active, missing, archived, and unsupported type selectors indistinguishable", async () => { + await dbModule + .getDb() + .insert(dbModule.schema.documentShares) + .values( + [sourceDatabasePageId, targetDatabasePageId, pageId].map( + (resourceId, index) => ({ + id: `${prefix}-share-${index}`, + resourceId, + principalType: "user", + principalId: viewer, + role: "viewer", + createdBy: owner, + }), + ), + ); + const state = { + pageId, + databaseId: sourceDatabaseId, + typeId, + propertyId, + surface: "picker" as const, + }; + await expect( + runWithRequestContext({ userEmail: viewer }, () => + resolveRelationshipScreenContext(state), + ), + ).resolves.toMatchObject(state); + + await dbModule + .getDb() + .delete(dbModule.schema.documentShares) + .where( + and( + eq(dbModule.schema.documentShares.resourceId, targetDatabasePageId), + eq(dbModule.schema.documentShares.principalId, viewer), + ), + ); + + await expect( + runWithRequestContext({ userEmail: viewer }, () => + Promise.all( + [ + typeId, + `${prefix}-missing-type`, + archivedTypeId, + unsupportedTypeId, + ].map((candidateTypeId) => + resolveRelationshipScreenContext({ + ...state, + typeId: candidateTypeId, + }), + ), + ), + ), + ).resolves.toEqual([null, null, null, null]); + }); + + it("reports malformed stored relationship context as unavailable", async () => { + await expect( + resolveRelationshipScreenContext({ + databaseId: sourceDatabaseId, + surface: "configuration", + actorEmail: "forged@example.test", + }), + ).rejects.toMatchObject({ + errorCode: "UNAVAILABLE", + message: "The relationship screen context is unreadable.", + }); + }); +}); + function property( id: string, name: string, diff --git a/templates/content/actions/view-screen.ts b/templates/content/actions/view-screen.ts index 282c587a4a3..211826a55af 100644 --- a/templates/content/actions/view-screen.ts +++ b/templates/content/actions/view-screen.ts @@ -1,10 +1,10 @@ -import { defineAction } from "@agent-native/core/action"; +import { defineAction, isActionContractError } from "@agent-native/core/action"; import { readAppState, readAppStateForCurrentTab, } from "@agent-native/core/application-state"; import { accessFilter, resolveAccess } from "@agent-native/core/sharing"; -import { and, asc, inArray, isNull } from "drizzle-orm"; +import { and, asc, eq, inArray, isNull } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -46,6 +46,151 @@ import { listPropertiesForDocument, serializeDatabase, } from "./_property-utils.js"; +import { + loadRelationshipDatabase, + loadRelationshipTypeBundle, + relationshipError, + resolveRelationshipDocumentAccess, +} from "./_relationship-core.js"; + +const relationshipScreenContextSchema = z + .object({ + pageId: z.string().min(1).optional(), + propertyId: z.string().min(1).optional(), + typeId: z.string().min(1).optional(), + databaseId: z.string().min(1).optional(), + selectedPageIds: z.array(z.string().min(1)).max(100).optional(), + surface: z.enum([ + "picker", + "connections", + "bulk", + "history", + "configuration", + ]), + }) + .strict() + .superRefine((context, refinement) => { + if (context.typeId && !context.databaseId) { + refinement.addIssue({ + code: "custom", + message: "A relationship type requires its database context.", + path: ["databaseId"], + }); + } + if (context.propertyId && (!context.databaseId || !context.typeId)) { + refinement.addIssue({ + code: "custom", + message: "A relation Property requires its database and type context.", + path: ["propertyId"], + }); + } + }); + +export async function resolveRelationshipScreenContext(state: unknown) { + if (state === null || state === undefined) return null; + const parsed = relationshipScreenContextSchema.safeParse(state); + if (!parsed.success) { + relationshipError( + "UNAVAILABLE", + "The relationship screen context is unreadable.", + { statusCode: 503 }, + ); + } + const context = parsed.data; + const db = getDb(); + const pageIds = [ + ...new Set([ + ...(context.pageId ? [context.pageId] : []), + ...(context.selectedPageIds ?? []), + ]), + ]; + try { + const accessiblePages = await Promise.all( + pageIds.map(async (pageId) => ({ + pageId, + access: await resolveRelationshipDocumentAccess(pageId, { db }), + })), + ); + const accessibleIds = new Set( + accessiblePages + .filter(({ access }) => access && !access.resource.trashedAt) + .map(({ pageId }) => pageId), + ); + if (context.pageId && !accessibleIds.has(context.pageId)) return null; + + if (context.databaseId) { + await loadRelationshipDatabase(context.databaseId, "viewer", db); + } + if (context.typeId) { + const bundle = await loadRelationshipTypeBundle(context.typeId, { db }); + await Promise.all([ + loadRelationshipDatabase(bundle.version.sourceDatabaseId, "viewer", db), + loadRelationshipDatabase(bundle.version.targetDatabaseId, "viewer", db), + ]); + if ( + context.databaseId !== bundle.version.sourceDatabaseId && + context.databaseId !== bundle.version.targetDatabaseId + ) { + relationshipError( + "UNAVAILABLE", + "The relationship screen context is unreadable.", + { statusCode: 503 }, + ); + } + } + if (context.propertyId) { + const [projection] = await db + .select({ id: schema.contentRelationshipProjections.id }) + .from(schema.contentRelationshipProjections) + .where( + and( + eq( + schema.contentRelationshipProjections.propertyId, + context.propertyId, + ), + eq( + schema.contentRelationshipProjections.relationshipTypeId, + context.typeId!, + ), + eq( + schema.contentRelationshipProjections.databaseId, + context.databaseId!, + ), + isNull(schema.contentRelationshipProjections.archivedAt), + ), + ); + if (!projection) { + relationshipError( + "UNAVAILABLE", + "The relationship screen context is unreadable.", + { statusCode: 503 }, + ); + } + } + return { + ...context, + selectedPageIds: context.selectedPageIds?.filter((id) => + accessibleIds.has(id), + ), + }; + } catch (error) { + if ( + isActionContractError(error) && + (error.errorCode === "NOT_ACCESSIBLE" || + error.errorCode === "TYPE_UNAVAILABLE" || + error.errorCode === "UNSUPPORTED_CONFIGURATION") + ) { + return null; + } + throw error; + } +} + +async function relationshipScreenContext() { + return resolveRelationshipScreenContext( + await readAppStateForCurrentTab("content-relationship-context"), + ); +} type ScreenTreeDocument = Pick< typeof schema.documents.$inferSelect, @@ -827,6 +972,8 @@ export default defineAction({ const selectionState = await readAppStateForCurrentTab("content-selection"); const screen: Record = {}; + const relationshipContext = await relationshipScreenContext(); + if (relationshipContext) screen.relationships = relationshipContext; if (navigation) screen.navigation = navigation; if (contentSpaceState) screen.contentSpace = contentSpaceState; diff --git a/templates/content/app/components/editor/ContentRelationships.test.ts b/templates/content/app/components/editor/ContentRelationships.test.ts new file mode 100644 index 00000000000..cfa174b4040 --- /dev/null +++ b/templates/content/app/components/editor/ContentRelationships.test.ts @@ -0,0 +1,193 @@ +import type { + ContentRelationshipHistoryChange, + ContentRelationshipHistoryItem, + ContentRelationshipItem, + RelationshipTypeVersion, +} from "@shared/relationships"; +import { describe, expect, it } from "vitest"; + +import { + relationshipHistoryAuthorizingPrincipal, + relationshipHistoryChangeDisplayText, + relationshipOppositeEndpoint, + relationshipPickerCandidates, + relationshipPropertyRemovalMode, + relationshipPropertyRemovalOperation, +} from "./ContentRelationships"; +import { relationshipDirectionForDatabase } from "./RelationPropertyConfigurationDialog"; + +const edge = { + sourcePageId: "deliverable", + targetPageId: "person", + source: { pageId: "deliverable", title: "Launch article", state: "active" }, + target: { pageId: "person", title: "Mira Chen", state: "active" }, +} as ContentRelationshipItem; + +describe("relationship UI helpers", () => { + it("resolves the opposite Page without depending on database membership", () => { + expect(relationshipOppositeEndpoint(edge, "deliverable")).toEqual( + edge.target, + ); + expect(relationshipOppositeEndpoint(edge, "person")).toEqual(edge.source); + }); + + it("binds an existing type in the direction owned by the database", () => { + const version = { + sourceDatabaseId: "deliverables", + targetDatabaseId: "people", + } as RelationshipTypeVersion; + + expect(relationshipDirectionForDatabase(version, "deliverables")).toBe( + "forward", + ); + expect(relationshipDirectionForDatabase(version, "people")).toBe("inverse"); + expect(relationshipDirectionForDatabase(version, "campaigns")).toBeNull(); + }); + + it("keeps a suspended selected Page removable when it is absent from candidates", () => { + expect( + relationshipPickerCandidates( + [], + [ + { + ...edge, + state: "suspended", + slotObservationToken: "slot-observation", + } as ContentRelationshipItem, + ], + "deliverable", + ), + ).toEqual([ + { + pageId: "person", + title: "Mira Chen", + context: {}, + slotObservationToken: "slot-observation", + }, + ]); + }); + + it("derives relationship history copy from safe labels instead of identifiers", () => { + const change = { + eventId: "event-secret-id", + kind: "replaced", + relationshipTypeId: "type-secret-id", + relationshipLabel: "Contributors", + source: { pageId: "source-secret-id", title: "Launch article" }, + target: { pageId: "target-secret-id", title: "Mira Chen" }, + previousTarget: { + pageId: "previous-secret-id", + title: "Jordan Lee", + }, + } satisfies ContentRelationshipHistoryChange; + + const text = relationshipHistoryChangeDisplayText(change); + expect(text).toEqual({ + endpoints: "Launch article → Mira Chen", + relationship: "Contributors", + previousTarget: "Jordan Lee", + }); + expect(JSON.stringify(text)).not.toContain("secret-id"); + }); + + it("shows an agent's authorizing principal separately without duplicating a person", () => { + const historyItem = { + actor: { + kind: "agent", + displayName: "Editorial agent", + networkProtocol: "a2a", + }, + authorizingPrincipal: { + kind: "user", + email: "alice@example.test", + }, + } as Pick; + expect(relationshipHistoryAuthorizingPrincipal(historyItem)).toBe( + "alice@example.test", + ); + expect( + relationshipHistoryAuthorizingPrincipal({ + actor: { + kind: "person", + displayName: "alice@example.test", + email: "alice@example.test", + }, + authorizingPrincipal: { + kind: "user", + email: "alice@example.test", + }, + }), + ).toBeNull(); + }); + + it("removes checked relationships against the original Property preview", () => { + expect( + relationshipPropertyRemovalMode({ + keep: false, + selectionReceipt: "property-preview-receipt", + selectedEdgeIds: ["edge-a", "edge-b"], + }), + ).toEqual({ + kind: "remove-selected", + selectionReceipt: "property-preview-receipt", + edgeIds: ["edge-a", "edge-b"], + }); + }); + + it("never turns an incomplete destructive selection into keep", () => { + expect( + relationshipPropertyRemovalMode({ + keep: false, + selectionReceipt: undefined, + selectedEdgeIds: ["edge-a"], + }), + ).toBeNull(); + expect( + relationshipPropertyRemovalMode({ + keep: false, + selectionReceipt: "property-preview-receipt", + selectedEdgeIds: [], + }), + ).toBeNull(); + expect( + relationshipPropertyRemovalMode({ + keep: true, + selectionReceipt: undefined, + selectedEdgeIds: [], + }), + ).toEqual({ kind: "keep" }); + }); + + it("reuses an operation ID only while retrying the same removal", () => { + let nextId = 0; + const createOperationId = () => `operation-${++nextId}`; + const mode = { + kind: "remove-selected" as const, + selectionReceipt: "property-preview-receipt", + edgeIds: ["edge-a"], + }; + const first = relationshipPropertyRemovalOperation( + null, + "property-a", + mode, + createOperationId, + ); + + expect( + relationshipPropertyRemovalOperation( + first, + "property-a", + mode, + createOperationId, + ).operationId, + ).toBe("operation-1"); + expect( + relationshipPropertyRemovalOperation( + first, + "property-a", + { ...mode, edgeIds: ["edge-b"] }, + createOperationId, + ).operationId, + ).toBe("operation-2"); + }); +}); diff --git a/templates/content/app/components/editor/ContentRelationships.tsx b/templates/content/app/components/editor/ContentRelationships.tsx new file mode 100644 index 00000000000..12dcab5770e --- /dev/null +++ b/templates/content/app/components/editor/ContentRelationships.tsx @@ -0,0 +1,1754 @@ +import { actionErrorMessage } from "@agent-native/core/client/hooks"; +import { useFormatters, useT } from "@agent-native/core/client/i18n"; +import type { ContentDatabaseItem, DocumentProperty } from "@shared/api"; +import type { + ContentRelationCandidate, + ContentRelationshipHistoryChange, + ContentRelationshipHistoryItem, + ContentRelationshipItem, + MutateContentRelationshipsResult, + RemoveContentRelationPropertyInput, + RemoveContentRelationPropertyResult, + RelationshipChange, + RelationshipRouteRef, +} from "@shared/relationships"; +import { + IconArrowLeft, + IconArrowRight, + IconArrowsExchange, + IconCheck, + IconChevronDown, + IconClockFilled, + IconHistory, + IconLink, + IconRotate, + IconSearch, + IconTrash, + IconX, +} from "@tabler/icons-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Link } from "react-router"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + canonicalRelationOptions, + contentRelationshipOperationId, + isSupersededRelationshipMutationError, + relationshipMutationErrorMessage, + useContentRelationCandidates, + useContentRelationshipHistory, + useContentRelationships, + useContentRelationshipTypes, + useMutateContentRelationships, + usePrepareContentRelationshipRemoval, + useRemoveContentRelationProperty, + useUndoContentRelationshipRevision, +} from "@/hooks/use-content-relationships"; +import { useRelationshipAppState } from "@/hooks/use-relationship-app-state"; +import { cn } from "@/lib/utils"; + +export function relationshipOppositeEndpoint( + edge: ContentRelationshipItem, + pageId: string, +) { + return edge.sourcePageId === pageId ? edge.target : edge.source; +} + +export function relationshipHistoryAuthorizingPrincipal( + item: Pick, +) { + const email = item.authorizingPrincipal.email; + if (typeof email !== "string" || !email.trim()) return null; + if ( + item.actor.kind === "person" && + (email === item.actor.email || email === item.actor.displayName) + ) { + return null; + } + return email; +} + +export function relationshipHistoryChangeDisplayText( + change: ContentRelationshipHistoryChange, +) { + return { + endpoints: `${change.source.title} → ${change.target.title}`, + relationship: change.relationshipLabel, + previousTarget: change.previousTarget?.title ?? null, + }; +} + +export function relationshipPickerCandidates( + candidates: ContentRelationCandidate[], + selectedEdges: ContentRelationshipItem[], + pageId: string, +) { + const byPageId = new Map( + candidates.map((candidate) => [candidate.pageId, candidate]), + ); + for (const edge of selectedEdges) { + const endpoint = relationshipOppositeEndpoint(edge, pageId); + if (!byPageId.has(endpoint.pageId)) { + byPageId.set(endpoint.pageId, { + pageId: endpoint.pageId, + title: endpoint.title, + context: {}, + slotObservationToken: edge.slotObservationToken, + }); + } + } + return Array.from(byPageId.values()); +} + +export function relationshipPropertyRemovalMode({ + keep, + selectionReceipt, + selectedEdgeIds, +}: { + keep: boolean; + selectionReceipt: string | undefined; + selectedEdgeIds: string[]; +}): RemoveContentRelationPropertyInput["relationshipMode"] | null { + if (keep) return { kind: "keep" }; + if (!selectionReceipt || selectedEdgeIds.length === 0) return null; + return { + kind: "remove-selected", + selectionReceipt, + edgeIds: [...selectedEdgeIds], + }; +} + +type RelationshipPropertyRemovalOperation = { + key: string; + operationId: string; +}; + +export function relationshipPropertyRemovalOperation( + current: RelationshipPropertyRemovalOperation | null, + propertyId: string, + relationshipMode: RemoveContentRelationPropertyInput["relationshipMode"], + createOperationId: () => string = contentRelationshipOperationId, +): RelationshipPropertyRemovalOperation { + const key = JSON.stringify({ propertyId, relationshipMode }); + return current?.key === key + ? current + : { key, operationId: createOperationId() }; +} + +function RelationshipMutationFailure({ + message, + onRetry, + pending, + className, +}: { + message: string; + onRetry?: () => void; + pending: boolean; + className?: string; +}) { + const t = useT(); + return ( +
+ {message} + {onRetry ? ( + + ) : null} +
+ ); +} + +function historyChangeKindMessage( + kind: ContentRelationshipHistoryChange["kind"], +) { + switch (kind) { + case "added": + return "relationships.historyChangeAdded" as const; + case "removed": + return "relationships.historyChangeRemoved" as const; + case "replaced": + return "relationships.historyChangeReplaced" as const; + case "restored": + return "relationships.historyChangeRestored" as const; + } +} + +function historyActorKindMessage( + kind: ContentRelationshipHistoryItem["actor"]["kind"], +) { + switch (kind) { + case "person": + return "relationships.historyActorPerson" as const; + case "agent": + return "relationships.historyActorAgent" as const; + case "automation": + return "relationships.historyActorAutomation" as const; + case "programmatic": + return "relationships.historyActorProgrammatic" as const; + } +} + +function RelationshipHistoryChangeRow({ + change, +}: { + change: ContentRelationshipHistoryChange; +}) { + const t = useT(); + return ( +
+
+ + {t(historyChangeKindMessage(change.kind))} + + + {change.relationshipLabel} + +
+
+ + {change.source.title} + + + + {change.target.title} + +
+ {change.previousTarget ? ( +
+ {t("relationships.historyPreviousTarget", { + name: change.previousTarget.title, + })} +
+ ) : null} +
+ ); +} + +function RelationshipHistoryDetails({ + item, +}: { + item: ContentRelationshipHistoryItem; +}) { + const t = useT(); + if (!Array.isArray(item.changes)) { + return ( +
+ {t("relationships.historyDetailsUnavailable")} +
+ ); + } + if (item.changes.length === 0) { + return
{item.summary}
; + } + return ( +
+ {item.changes.map((change) => ( + + ))} +
+ ); +} + +function relationDirection(property: DocumentProperty) { + const relation = canonicalRelationOptions(property); + if (!relation) return null; + return relation.direction === "forward" ? "outgoing" : "incoming"; +} + +function propertyRoute( + property: DocumentProperty, + pageId: string, +): RelationshipRouteRef | null { + const relation = canonicalRelationOptions(property); + if (!relation || !relation.editable) return null; + return relation.direction === "forward" + ? { + kind: "forward-property", + propertyId: property.definition.id, + sourcePageId: pageId, + } + : { + kind: "inverse-property", + propertyId: property.definition.id, + targetPageId: pageId, + }; +} + +function edgePropertyRoute( + edge: ContentRelationshipItem, + property: DocumentProperty, +) { + return ( + edge.routes.find( + (route) => + (route.kind === "forward-property" || + route.kind === "inverse-property") && + route.propertyId === property.definition.id, + ) ?? null + ); +} + +function candidateContext(candidate: ContentRelationCandidate) { + return Object.values(candidate.context) + .flatMap((value) => { + if (typeof value === "string" || typeof value === "number") { + return String(value).trim(); + } + return []; + }) + .filter(Boolean) + .slice(0, 2); +} + +export function RelationValueSummary({ + property, + pageId, + fallback, +}: { + property: DocumentProperty; + pageId: string; + fallback?: React.ReactNode; +}) { + const t = useT(); + const relation = canonicalRelationOptions(property); + const direction = relationDirection(property); + const query = useContentRelationships( + relation && direction + ? { + pageId, + relationshipTypeId: relation.relationshipTypeId, + direction, + limit: 100, + } + : null, + ); + + if (!relation) return fallback ?? null; + if (query.isLoading && !query.data) { + return ; + } + if (query.isError) { + return ( + + {t("relationships.valueUnavailable")} + + ); + } + const items = query.data?.items ?? []; + if (items.length === 0) { + return ( + {t("relationships.empty")} + ); + } + + return ( + + {items.slice(0, 3).map((edge) => { + const endpoint = relationshipOppositeEndpoint(edge, pageId); + return ( + + {endpoint.title} + {edge.state !== "active" ? ( + + · {t(`relationships.states.${edge.state}`)} + + ) : null} + + ); + })} + {items.length > 3 ? ( + + {t("relationships.moreCount", { count: items.length - 3 })} + + ) : null} + + ); +} + +export function RelationValueEditor({ + property, + pageId, + onDone, +}: { + property: DocumentProperty; + pageId: string; + onDone: () => void; +}) { + const t = useT(); + const relation = canonicalRelationOptions(property); + const direction = relationDirection(property); + const [query, setQuery] = useState(""); + const relationships = useContentRelationships( + relation && direction + ? { + pageId, + relationshipTypeId: relation.relationshipTypeId, + direction, + limit: 100, + } + : null, + ); + const candidates = useContentRelationCandidates( + relation + ? { + propertyId: property.definition.id, + anchorPageId: pageId, + search: query, + limit: 100, + contextPropertyIds: [], + } + : null, + ); + const relationshipTypes = useContentRelationshipTypes( + relation && property.definition.databaseId + ? { databaseId: property.definition.databaseId, limit: 100 } + : null, + ); + const mutate = useMutateContentRelationships(); + const editorRef = useRef(null); + const inputRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(0); + const [optimisticIds, setOptimisticIds] = useState(null); + const [error, setError] = useState(null); + + useRelationshipAppState( + relation + ? { + pageId, + propertyId: property.definition.id, + typeId: relation.relationshipTypeId, + databaseId: property.definition.databaseId ?? undefined, + surface: "picker", + } + : null, + ); + + useEffect(() => { + const frame = requestAnimationFrame(() => inputRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, []); + + useEffect(() => { + const closePicker = (event: KeyboardEvent) => { + if ( + event.key !== "Escape" || + !(event.target instanceof Node) || + !editorRef.current?.contains(event.target) + ) { + return; + } + event.preventDefault(); + event.stopImmediatePropagation(); + onDone(); + }; + window.addEventListener("keydown", closePicker, { capture: true }); + return () => + window.removeEventListener("keydown", closePicker, { capture: true }); + }, [onDone]); + + const selectedEdges = relationships.data?.items ?? []; + const serverSelectedIds = selectedEdges.map( + (edge) => relationshipOppositeEndpoint(edge, pageId).pageId, + ); + const selectedIds = optimisticIds ?? serverSelectedIds; + const serverSelectedKey = [...serverSelectedIds].sort().join("\u0000"); + const optimisticSelectedKey = optimisticIds + ? [...optimisticIds].sort().join("\u0000") + : null; + const typeDescriptor = relationshipTypes.data?.items.find( + (item) => item.type.id === relation?.relationshipTypeId, + ); + + useEffect(() => { + if ( + optimisticSelectedKey !== null && + serverSelectedKey === optimisticSelectedKey + ) { + setOptimisticIds(null); + } + }, [optimisticSelectedKey, serverSelectedKey]); + const projectionCardinality = + relation?.direction === "forward" + ? typeDescriptor?.version.forwardCardinality + : typeDescriptor?.version.inverseCardinality; + const pickerCandidates = useMemo( + () => + relationshipPickerCandidates( + candidates.data?.items ?? [], + selectedEdges, + pageId, + ), + [candidates.data?.items, pageId, selectedEdges], + ); + const filteredCandidates = useMemo(() => { + const normalized = query.trim().toLowerCase(); + return pickerCandidates.filter( + (candidate) => + !normalized || + candidate.title.toLowerCase().includes(normalized) || + candidateContext(candidate).some((value) => + value.toLowerCase().includes(normalized), + ), + ); + }, [pickerCandidates, query]); + + async function toggle(candidate: ContentRelationCandidate) { + if (!relation || !typeDescriptor || mutate.isPending) return; + const before = selectedIds; + const selected = before.includes(candidate.pageId); + setError(null); + setOptimisticIds( + selected + ? before.filter((id) => id !== candidate.pageId) + : projectionCardinality === "one" + ? [candidate.pageId] + : [...before, candidate.pageId], + ); + + try { + let change: RelationshipChange; + if (selected) { + const edge = selectedEdges.find( + (item) => + relationshipOppositeEndpoint(item, pageId).pageId === + candidate.pageId, + ); + const route = edge ? edgePropertyRoute(edge, property) : null; + if (!edge || !route) { + throw new Error(t("relationships.routeUnavailable")); + } + change = { + kind: "remove", + edgeId: edge.edgeId, + observedActivationIds: edge.observedActivationIds, + observationToken: edge.observationToken, + route, + }; + } else { + const route = propertyRoute(property, pageId); + if (!route) throw new Error(t("relationships.routeUnavailable")); + const sourcePageId = + relation.direction === "forward" ? pageId : candidate.pageId; + const targetPageId = + relation.direction === "forward" ? candidate.pageId : pageId; + if (typeDescriptor.version.forwardCardinality === "one") { + const slotObservationToken = + relation.direction === "forward" + ? candidates.data?.slotObservationToken + : candidate.slotObservationToken; + if (!slotObservationToken) { + throw new Error(t("relationships.refreshBeforeReplacing")); + } + change = { + kind: "replace", + typeId: relation.relationshipTypeId, + typeVersionId: typeDescriptor.version.id, + sourcePageId, + targetPageId, + observedSlotToken: slotObservationToken, + route, + }; + } else { + change = { + kind: "add", + typeId: relation.relationshipTypeId, + typeVersionId: typeDescriptor.version.id, + sourcePageId, + targetPageId, + route, + }; + } + } + await mutate.mutateAsync({ + operationId: contentRelationshipOperationId(), + changes: [change], + }); + if (projectionCardinality === "one") onDone(); + } catch (caught) { + if (isSupersededRelationshipMutationError(caught)) return; + setOptimisticIds(before); + setError( + relationshipMutationErrorMessage( + caught, + t("relationships.requestInterrupted"), + t("relationships.updateFailed"), + ), + ); + } + } + + async function retryToggle() { + setError(null); + try { + await mutate.retryFailed(); + if (projectionCardinality === "one") onDone(); + } catch (caught) { + if (isSupersededRelationshipMutationError(caught)) return; + setError( + relationshipMutationErrorMessage( + caught, + t("relationships.requestInterrupted"), + t("relationships.updateFailed"), + ), + ); + } + } + + if (!relation) { + return ( +
+ {t("relationships.legacyUnsupported")} +
+ ); + } + + const loading = + (relationships.isLoading && !relationships.data) || + (candidates.isLoading && !candidates.data) || + (relationshipTypes.isLoading && !relationshipTypes.data); + const loadError = + relationships.isError || candidates.isError || relationshipTypes.isError; + + return ( +
+
+ + { + setQuery(event.target.value); + setActiveIndex(0); + }} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((current) => + Math.min(filteredCandidates.length - 1, current + 1), + ); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((current) => Math.max(0, current - 1)); + } else if (event.key === "Enter") { + const candidate = filteredCandidates[activeIndex]; + if (candidate) { + event.preventDefault(); + void toggle(candidate); + } + } + }} + className="h-7 border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0" + /> +
+
+ {loading ? ( +
+ + +
+ ) : loadError ? ( +
+ {t("relationships.loadFailed")} + +
+ ) : filteredCandidates.length === 0 ? ( +
+ {t("relationships.noMatchingPages")} +
+ ) : ( + filteredCandidates.map((candidate, index) => { + const selected = selectedIds.includes(candidate.pageId); + const context = candidateContext(candidate); + const selectedEdge = selectedEdges.find( + (edge) => + relationshipOppositeEndpoint(edge, pageId).pageId === + candidate.pageId, + ); + return ( + + ); + }) + )} +
+ {error ? ( + void retryToggle() : undefined + } + className="px-1 text-xs" + /> + ) : null} +
+ +
+
+ ); +} + +function ConnectionRow({ + edge, + pageId, + onRemove, + pending, +}: { + edge: ContentRelationshipItem; + pageId: string; + onRemove: (edge: ContentRelationshipItem) => void; + pending: boolean; +}) { + const t = useT(); + const endpoint = relationshipOppositeEndpoint(edge, pageId); + const canRemove = + edge.routes.length > 0 && edge.observedActivationIds.length > 0; + return ( +
+ {edge.direction === "outgoing" ? ( + + ) : ( + + )} +
+
+ {edge.relationship.label} +
+ + {endpoint.title} + +
+ {edge.state !== "active" ? ( + + {t(`relationships.states.${edge.state}`)} + + ) : null} + {canRemove ? ( + + ) : null} +
+ ); +} + +export function ContentConnectionsSection({ pageId }: { pageId: string }) { + const t = useT(); + const formatters = useFormatters(); + const relationships = useContentRelationships({ + pageId, + direction: "both", + limit: 100, + }); + const history = useContentRelationshipHistory({ pageId, limit: 50 }); + const mutate = useMutateContentRelationships(); + const undo = useUndoContentRelationshipRevision(); + const [historyOpen, setHistoryOpen] = useState(false); + const [error, setError] = useState(null); + + useRelationshipAppState({ + pageId, + surface: historyOpen ? "history" : "connections", + }); + + async function finishRemoval( + result: MutateContentRelationshipsResult, + routes: RelationshipRouteRef[], + ) { + const refreshedHistory = await history.refetch(); + const recovery = refreshedHistory.data?.items.find( + (item) => item.revisionId === result.revisionId, + )?.recovery.recoveryToken; + toast.success( + t("relationships.connectionRemoved"), + recovery + ? { + action: { + label: t("relationships.undo"), + onClick: () => { + void undoHistory(result.revisionId, recovery, routes); + }, + }, + } + : undefined, + ); + } + + async function remove(edge: ContentRelationshipItem) { + const route = edge.routes[0]; + if (!route || edge.observedActivationIds.length === 0) return; + undo.clearFailedRequest(); + setError(null); + try { + const result = await mutate.mutateAsync({ + operationId: contentRelationshipOperationId(), + changes: [ + { + kind: "remove", + edgeId: edge.edgeId, + observedActivationIds: edge.observedActivationIds, + observationToken: edge.observationToken, + route, + }, + ], + }); + await finishRemoval(result, [route]); + } catch (caught) { + if (isSupersededRelationshipMutationError(caught)) return; + setError( + relationshipMutationErrorMessage( + caught, + t("relationships.requestInterrupted"), + t("relationships.removeFailed"), + ), + ); + } + } + + async function undoHistory( + revisionId: string, + recoveryToken: string, + routes: RelationshipRouteRef[] = [], + ) { + mutate.clearFailedRequest(); + setError(null); + try { + await undo.mutateAsync({ + revisionId, + recoveryToken, + operationId: contentRelationshipOperationId(), + routes, + }); + toast.success(t("relationships.changeUndone")); + } catch (caught) { + if (isSupersededRelationshipMutationError(caught)) return; + setError( + relationshipMutationErrorMessage( + caught, + t("relationships.requestInterrupted"), + t("relationships.undoFailed"), + ), + ); + } + } + + async function retryConnectionChange() { + setError(null); + try { + if (mutate.failedVariables) { + const failedRequest = mutate.failedVariables; + const result = await mutate.retryFailed(); + const routes = failedRequest.changes.map((change) => change.route); + await finishRemoval(result, routes); + return; + } + if (undo.failedVariables) { + await undo.retryFailed(); + toast.success(t("relationships.changeUndone")); + } + } catch (caught) { + if (isSupersededRelationshipMutationError(caught)) return; + setError( + relationshipMutationErrorMessage( + caught, + t("relationships.requestInterrupted"), + mutate.failedVariables + ? t("relationships.removeFailed") + : t("relationships.undoFailed"), + ), + ); + } + } + + return ( +
+
+

+ {t("relationships.connections")} +

+ {(relationships.data?.items.length ?? 0) > 0 ? ( + + {formatters.formatNumber(relationships.data?.items.length ?? 0)} + + ) : null} +
+ {relationships.isLoading && !relationships.data ? ( +
+ + +
+ ) : relationships.isError ? ( + + ) : relationships.data?.items.length ? ( +
+ {relationships.data.items.map((edge) => ( + void remove(item)} + /> + ))} +
+ ) : ( +
+ {t("relationships.noConnections")} +
+ )} + {error ? ( + void retryConnectionChange() + : undefined + } + className="px-2 py-1 text-xs" + /> + ) : null} + + + + + + {history.isLoading && !history.data ? ( +
+ + +
+ ) : history.isError ? ( + + ) : history.data?.items.length ? ( + history.data.items.map((item) => ( +
+ +
+ +
+ {t("relationships.historyActor", { + kind: t(historyActorKindMessage(item.actor.kind)), + name: item.actor.displayName, + })} + {" · "} + {t("relationships.historyOrigin", { origin: item.origin })} + {" · "} + {formatters.formatDate(item.committedAt, { + dateStyle: "medium", + timeStyle: "short", + })} +
+ {relationshipHistoryAuthorizingPrincipal(item) ? ( +
+ {t("relationships.historyAuthorizedBy", { + principal: + relationshipHistoryAuthorizingPrincipal(item)!, + })} +
+ ) : null} +
+ {item.recovery.allowed && item.recovery.recoveryToken ? ( + + ) : null} +
+ )) + ) : ( +
+ {t("relationships.noHistory")} +
+ )} +
+
+
+ ); +} + +export function RelationBulkValueEditor({ + property, + selectedItems, + disabled, + onDone, +}: { + property: DocumentProperty; + selectedItems: ContentDatabaseItem[]; + disabled: boolean; + onDone: () => void; +}) { + const t = useT(); + const relation = canonicalRelationOptions(property); + const selectedPageIds = selectedItems.map((item) => item.document.id); + const anchorPageId = selectedPageIds[0] ?? ""; + const [query, setQuery] = useState(""); + const relationships = useContentRelationships( + relation && property.definition.databaseId + ? { + databaseId: property.definition.databaseId, + relationshipTypeId: relation.relationshipTypeId, + direction: "both", + limit: 100, + } + : null, + ); + const candidates = useContentRelationCandidates( + relation && anchorPageId + ? { + propertyId: property.definition.id, + anchorPageId, + search: query, + limit: 100, + contextPropertyIds: [], + } + : null, + ); + const types = useContentRelationshipTypes( + relation && property.definition.databaseId + ? { databaseId: property.definition.databaseId, limit: 100 } + : null, + ); + const mutate = useMutateContentRelationships(); + const [mode, setMode] = useState<"add" | "remove">("add"); + const [selectedOppositePageId, setSelectedOppositePageId] = useState(""); + const [error, setError] = useState(null); + + useRelationshipAppState( + relation + ? { + propertyId: property.definition.id, + typeId: relation.relationshipTypeId, + databaseId: property.definition.databaseId ?? undefined, + selectedPageIds, + surface: "bulk", + } + : null, + ); + + const selectedSet = useMemo( + () => new Set(selectedPageIds), + [selectedPageIds], + ); + const relevantEdges = (relationships.data?.items ?? []).filter((edge) => + relation?.direction === "inverse" + ? selectedSet.has(edge.targetPageId) + : selectedSet.has(edge.sourcePageId), + ); + const removeCandidates = Array.from( + new Map( + relevantEdges.map((edge) => { + const anchor = + relation?.direction === "inverse" + ? edge.targetPageId + : edge.sourcePageId; + const endpoint = relationshipOppositeEndpoint(edge, anchor); + return [ + endpoint.pageId, + { + pageId: endpoint.pageId, + title: endpoint.title, + context: {}, + slotObservationToken: null, + }, + ]; + }), + ).values(), + ); + const availableCandidates = + mode === "add" ? (candidates.data?.items ?? []) : removeCandidates; + const normalizedQuery = query.trim().toLowerCase(); + const visibleCandidates = availableCandidates.filter( + (candidate) => + !normalizedQuery || + candidate.title.toLowerCase().includes(normalizedQuery), + ); + const typeDescriptor = types.data?.items.find( + (item) => item.type.id === relation?.relationshipTypeId, + ); + const selectedCandidate = availableCandidates.find( + (candidate) => candidate.pageId === selectedOppositePageId, + ); + + async function apply() { + if (!relation || !typeDescriptor || !selectedOppositePageId) return; + setError(null); + try { + const changes: RelationshipChange[] = []; + if (mode === "add") { + if ( + relation.direction === "inverse" && + typeDescriptor.version.forwardCardinality === "one" && + selectedPageIds.length > 1 + ) { + throw new Error(t("relationships.bulkInverseMaxOne")); + } + for (const pageId of selectedPageIds) { + const route = propertyRoute(property, pageId); + if (!route) throw new Error(t("relationships.routeUnavailable")); + const sourcePageId = + relation.direction === "forward" ? pageId : selectedOppositePageId; + const targetPageId = + relation.direction === "forward" ? selectedOppositePageId : pageId; + const current = relevantEdges.find( + (edge) => + relation.direction === "forward" && edge.sourcePageId === pageId, + ); + if ( + typeDescriptor.version.forwardCardinality === "one" && + (relation.direction === "inverse" || + (current && + relationshipOppositeEndpoint(current, pageId).pageId !== + selectedOppositePageId)) + ) { + const slotObservationToken = + relation.direction === "inverse" + ? selectedCandidate?.slotObservationToken + : current?.slotObservationToken; + if (!slotObservationToken) { + throw new Error(t("relationships.refreshBeforeReplacing")); + } + changes.push({ + kind: "replace", + typeId: relation.relationshipTypeId, + typeVersionId: typeDescriptor.version.id, + sourcePageId, + targetPageId, + observedSlotToken: slotObservationToken, + route, + }); + } else { + changes.push({ + kind: "add", + typeId: relation.relationshipTypeId, + typeVersionId: typeDescriptor.version.id, + sourcePageId, + targetPageId, + route, + }); + } + } + } else { + for (const edge of relevantEdges) { + const anchor = + relation.direction === "forward" + ? edge.sourcePageId + : edge.targetPageId; + if ( + relationshipOppositeEndpoint(edge, anchor).pageId !== + selectedOppositePageId + ) { + continue; + } + const route = edgePropertyRoute(edge, property); + if (!route) throw new Error(t("relationships.routeUnavailable")); + changes.push({ + kind: "remove", + edgeId: edge.edgeId, + observedActivationIds: edge.observedActivationIds, + observationToken: edge.observationToken, + route, + }); + } + } + if (changes.length === 0) return; + const result = await mutate.mutateAsync({ + operationId: contentRelationshipOperationId(), + changes, + }); + toast.success( + t( + mode === "add" + ? "relationships.bulkAdded" + : "relationships.bulkRemoved", + { count: result.results.length }, + ), + ); + onDone(); + } catch (caught) { + if (isSupersededRelationshipMutationError(caught)) return; + setError( + relationshipMutationErrorMessage( + caught, + t("relationships.requestInterrupted"), + t("relationships.bulkFailed"), + ), + ); + } + } + + async function retryApply() { + setError(null); + try { + const result = await mutate.retryFailed(); + toast.success( + t( + mode === "add" + ? "relationships.bulkAdded" + : "relationships.bulkRemoved", + { count: result.results.length }, + ), + ); + onDone(); + } catch (caught) { + if (isSupersededRelationshipMutationError(caught)) return; + setError( + relationshipMutationErrorMessage( + caught, + t("relationships.requestInterrupted"), + t("relationships.bulkFailed"), + ), + ); + } + } + + if (!relation) { + return ( +
+ {t("relationships.legacyUnsupported")} +
+ ); + } + return ( +
+
+ {(["add", "remove"] as const).map((value) => ( + + ))} +
+
+ + setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") onDone(); + }} + /> +
+
+ {visibleCandidates.length === 0 ? ( +
+ {mode === "remove" + ? t("relationships.noSharedRelationships") + : t("relationships.noMatchingPages")} +
+ ) : ( + visibleCandidates.map((candidate) => ( + + )) + )} +
+ {error ? ( + void retryApply() : undefined} + className="text-xs" + /> + ) : null} +
+ + +
+
+ ); +} + +export function RelationPropertyDeletionDialog({ + open, + property, + onOpenChange, + onDeleted, +}: { + open: boolean; + property: DocumentProperty; + onOpenChange: (open: boolean) => void; + onDeleted: () => void; +}) { + const t = useT(); + const relation = canonicalRelationOptions(property); + const prepare = usePrepareContentRelationshipRemoval(); + const remove = useRemoveContentRelationProperty(); + const undo = useUndoContentRelationshipRevision(); + const relationships = useContentRelationships( + relation && property.definition.databaseId + ? { + databaseId: property.definition.databaseId, + relationshipTypeId: relation.relationshipTypeId, + direction: "both", + limit: 100, + } + : null, + ); + const [selectedEdgeIds, setSelectedEdgeIds] = useState([]); + const [previewQuery, setPreviewQuery] = useState(""); + const [error, setError] = useState(null); + const removalOperationRef = + useRef(null); + + useRelationshipAppState( + open && relation + ? { + propertyId: property.definition.id, + typeId: relation.relationshipTypeId, + databaseId: property.definition.databaseId ?? undefined, + surface: "configuration", + } + : null, + ); + + useEffect(() => { + if (!open) return; + setSelectedEdgeIds([]); + setPreviewQuery(""); + setError(null); + removalOperationRef.current = null; + prepare.reset(); + void prepare + .mutateAsync({ + selection: { kind: "property", propertyId: property.definition.id }, + }) + .catch((caught) => { + setError( + actionErrorMessage(caught) ?? t("relationships.removalPreviewFailed"), + ); + }); + }, [open, property.definition.id]); + + async function retryPropertyUndo() { + try { + await undo.retryFailed(); + toast.success(t("relationships.changeUndone")); + } catch (caught) { + if (isSupersededRelationshipMutationError(caught)) return; + toast.error( + relationshipMutationErrorMessage( + caught, + t("relationships.requestInterrupted"), + t("relationships.undoFailed"), + ), + { + action: { + label: t("relationships.retrySavedChange"), + onClick: () => void retryPropertyUndo(), + }, + }, + ); + } + } + + function finishPropertyRemoval(result: RemoveContentRelationPropertyResult) { + removalOperationRef.current = null; + onOpenChange(false); + onDeleted(); + toast.success(t("relationships.propertyRemoved"), { + action: { + label: t("relationships.undo"), + onClick: () => { + void undo + .mutateAsync({ + revisionId: result.undo.revisionId, + recoveryToken: result.undo.recoveryToken, + operationId: contentRelationshipOperationId(), + routes: [], + }) + .then(() => toast.success(t("relationships.changeUndone"))) + .catch((caught) => { + if (isSupersededRelationshipMutationError(caught)) return; + toast.error( + relationshipMutationErrorMessage( + caught, + t("relationships.requestInterrupted"), + t("relationships.undoFailed"), + ), + { + action: { + label: t("relationships.retrySavedChange"), + onClick: () => void retryPropertyUndo(), + }, + }, + ); + }); + }, + }, + }); + } + + async function commit(keep: boolean) { + setError(null); + try { + const relationshipMode = relationshipPropertyRemovalMode({ + keep, + selectionReceipt: prepare.data?.selectionReceipt, + selectedEdgeIds, + }); + if (!relationshipMode) { + setError(t("relationships.removalPreviewFailed")); + return; + } + + removalOperationRef.current = relationshipPropertyRemovalOperation( + removalOperationRef.current, + property.definition.id, + relationshipMode, + ); + const result = await remove.mutateAsync({ + propertyId: property.definition.id, + relationshipMode, + operationId: removalOperationRef.current.operationId, + }); + finishPropertyRemoval(result); + } catch (caught) { + if (isSupersededRelationshipMutationError(caught)) return; + setError( + relationshipMutationErrorMessage( + caught, + t("relationships.requestInterrupted"), + t("relationships.removePropertyFailed"), + ), + ); + } + } + + async function retryPropertyRemoval() { + setError(null); + try { + finishPropertyRemoval(await remove.retryFailed()); + } catch (caught) { + if (isSupersededRelationshipMutationError(caught)) return; + setError( + relationshipMutationErrorMessage( + caught, + t("relationships.requestInterrupted"), + t("relationships.removePropertyFailed"), + ), + ); + } + } + + const edgeById = new Map( + (relationships.data?.items ?? []).map((edge) => [edge.edgeId, edge]), + ); + const previewEdges = (prepare.data?.edges ?? []) + .map((edge) => edgeById.get(edge.edgeId)) + .filter((edge): edge is ContentRelationshipItem => !!edge); + const normalizedPreviewQuery = previewQuery.trim().toLowerCase(); + const visiblePreviewEdges = previewEdges.filter( + (edge) => + !normalizedPreviewQuery || + edge.source.title.toLowerCase().includes(normalizedPreviewQuery) || + edge.target.title.toLowerCase().includes(normalizedPreviewQuery) || + edge.relationship.label.toLowerCase().includes(normalizedPreviewQuery), + ); + + return ( + + + + {t("relationships.removeProperty")} + +
+
+ {t("relationships.removePropertyNamed", { + name: property.definition.name, + })} +
+
+
+ + + {t("relationships.relationshipsPreserved")} + +
+
+ {prepare.isPending || relationships.isLoading ? ( +
+ + +
+ ) : previewEdges.length > 0 ? ( +
+
+ {t("relationships.selectRelationshipsToRemove")} +
+
+ + setPreviewQuery(event.target.value)} + /> +
+ {visiblePreviewEdges.map((edge) => { + const selected = selectedEdgeIds.includes(edge.edgeId); + return ( + + ); + })} + {visiblePreviewEdges.length === 0 ? ( +
+ {t("relationships.noMatchingRelationships")} +
+ ) : null} +
+ ) : null} + {error ? ( + void retryPropertyRemoval() + : undefined + } + className="mt-3 text-sm" + /> + ) : null} +
+ + +
+ + +
+
+
+
+ ); +} diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index 5a80ed6d29a..00c15cca1f2 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -887,6 +887,9 @@ describe("document editor layout", () => { ); expect(infoPanel).toContain(" { clearCommentFocus(); - if (!hasUtilityRailSpace) { + if (!hasUtilityRailSpace && utilityPanel === "comments") { setCommentsBrowseOpen(false); setUtilityPanel(null); } - }, [clearCommentFocus, hasUtilityRailSpace]); + }, [clearCommentFocus, hasUtilityRailSpace, utilityPanel]); const activateCommentThread = useCallback( (threadId: string, preserveBrowseContext = false) => { @@ -3352,6 +3352,7 @@ function PageEditorSessionBody({ databaseId={databaseId} databaseDocumentId={databaseDocumentId} canEdit={editorCanEdit} + popoversPortalled={!inSheet} onSaveDescription={(description) => persistDocumentUpdates({ description }) } diff --git a/templates/content/app/components/editor/DocumentInfoPanel.tsx b/templates/content/app/components/editor/DocumentInfoPanel.tsx index d7246316584..512dc5c4d5d 100644 --- a/templates/content/app/components/editor/DocumentInfoPanel.tsx +++ b/templates/content/app/components/editor/DocumentInfoPanel.tsx @@ -13,6 +13,7 @@ import { useDocumentProperties, } from "@/hooks/use-document-properties"; +import { ContentConnectionsSection } from "./ContentRelationships"; import { DescriptionField } from "./DescriptionField"; import { DocumentProperties } from "./DocumentProperties"; @@ -23,6 +24,7 @@ interface DocumentInfoPanelProps { databaseId?: string | null; databaseDocumentId?: string | null; canEdit: boolean; + popoversPortalled?: boolean; onSaveDescription: (description: string) => Promise; } @@ -33,6 +35,7 @@ export function DocumentInfoPanel({ databaseId, databaseDocumentId, canEdit, + popoversPortalled = true, onSaveDescription, }: DocumentInfoPanelProps) { const t = useT(); @@ -105,6 +108,9 @@ export function DocumentInfoPanel({ )} + {!isLocalFileDocument ? ( + + ) : null} {document.databaseMembership && !isLocalFileDocument ? ( ) : null} diff --git a/templates/content/app/components/editor/DocumentProperties.test.ts b/templates/content/app/components/editor/DocumentProperties.test.ts index b396d6a5cfe..7690c7003f8 100644 --- a/templates/content/app/components/editor/DocumentProperties.test.ts +++ b/templates/content/app/components/editor/DocumentProperties.test.ts @@ -57,7 +57,7 @@ describe("document property type picker", () => { expect(filterDocumentPropertyTypes("files")).toEqual(["files_media"]); expect(filterDocumentPropertyTypes("calculation")).toEqual([]); expect(filterDocumentPropertyTypes("formula")).toEqual([]); - expect(filterDocumentPropertyTypes("database")).toEqual([]); + expect(filterDocumentPropertyTypes("database")).toEqual(["relation"]); expect(filterDocumentPropertyTypes("aggregate")).toEqual([]); expect(filterDocumentPropertyTypes("multi select")).toEqual([ "multi_select", @@ -69,7 +69,7 @@ describe("document property type picker", () => { expect(filterDocumentPropertyTypes("")).toContain("person"); expect(filterDocumentPropertyTypes("")).toContain("place"); expect(filterDocumentPropertyTypes("")).toContain("files_media"); - expect(filterDocumentPropertyTypes("")).not.toContain("relation"); + expect(filterDocumentPropertyTypes("")).toContain("relation"); expect(filterDocumentPropertyTypes("")).not.toContain("rollup"); expect(filterDocumentPropertyTypes("")).not.toContain("formula"); expect(filterDocumentPropertyTypes("")).toContain("last_edited_time"); diff --git a/templates/content/app/components/editor/DocumentProperties.tsx b/templates/content/app/components/editor/DocumentProperties.tsx index 7e3c8917d06..a9fe49034e4 100644 --- a/templates/content/app/components/editor/DocumentProperties.tsx +++ b/templates/content/app/components/editor/DocumentProperties.tsx @@ -108,6 +108,7 @@ import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuCheckboxItem, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, @@ -130,6 +131,13 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useAddContentDatabaseSourceFieldProperty } from "@/hooks/use-content-database"; +import { + canonicalRelationOptions, + contentRelationshipOperationId, + isSupersededRelationshipMutationError, + relationshipMutationErrorMessage, + useConfigureContentRelationProperty, +} from "@/hooks/use-content-relationships"; import { documentPropertiesResponseMatchesScope, useConfigureDocumentProperty, @@ -140,6 +148,11 @@ import { } from "@/hooks/use-document-properties"; import { cn } from "@/lib/utils"; +import { + RelationPropertyDeletionDialog, + RelationValueEditor, + RelationValueSummary, +} from "./ContentRelationships"; import { ColumnPresentationMenuItems } from "./database/DatabaseColumnPresentation"; import { clearDatabaseFiltersForColumn, @@ -150,6 +163,7 @@ import { } from "./database/filter-sort"; import type { DatabaseFilter, DatabaseSort } from "./database/types"; import { imageUploadErrorMessage, uploadImageFile } from "./image-upload"; +import { RelationPropertyConfigurationDialog } from "./RelationPropertyConfigurationDialog"; type TFunction = ReturnType; @@ -1015,7 +1029,15 @@ function PropertyRow({ const Icon = TYPE_ICONS[property.definition.type]; const value = (
- {displayValue(property, t)} + {property.definition.type === "relation" ? ( + + ) : ( + displayValue(property, t) + )}
); @@ -1142,6 +1164,8 @@ export function PropertyManagementPopover({ property.definition.type, ); const configure = useConfigureDocumentProperty(documentId, databaseId); + const configureRelationMetadata = useConfigureContentRelationProperty(); + const configureInverseRelation = useConfigureContentRelationProperty(); const duplicate = useDuplicateDocumentProperty(documentId, databaseId); const remove = useDeleteDocumentProperty(documentId, databaseId); const { data: propertiesData } = useDocumentProperties( @@ -1218,6 +1242,7 @@ export function PropertyManagementPopover({ hasColumnMenu ? "quick" : "edit", ); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const canonicalRelation = canonicalRelationOptions(property); const [name, setName] = useState(property.definition.name); const [description, setDescription] = useState( property.definition.description, @@ -1309,11 +1334,81 @@ export function PropertyManagementPopover({ } persistMetadataSnapshotRef.current = (metadata) => - configure.mutateAsync({ - id: property.definition.id, - documentId, - ...metadata, - }); + canonicalRelation + ? configureRelationMetadata.mutateAsync({ + ownerDatabaseId: databaseId, + propertyId: property.definition.id, + alias: metadata.name, + description: metadata.description, + visibility: metadata.visibility, + definition: { + kind: "existing", + relationshipTypeId: canonicalRelation.relationshipTypeId, + direction: canonicalRelation.direction, + }, + operationId: contentRelationshipOperationId(), + }) + : configure.mutateAsync({ + id: property.definition.id, + documentId, + ...metadata, + }); + + async function updateInverseEditing(editable: boolean) { + if (!canonicalRelation || canonicalRelation.direction !== "inverse") return; + try { + await configureInverseRelation.mutateAsync({ + ownerDatabaseId: databaseId, + propertyId: property.definition.id, + alias: property.definition.name, + definition: { + kind: "existing", + relationshipTypeId: canonicalRelation.relationshipTypeId, + direction: "inverse", + }, + editable, + operationId: contentRelationshipOperationId(), + }); + } catch (caught) { + if (isSupersededRelationshipMutationError(caught)) return; + toast.error( + relationshipMutationErrorMessage( + caught, + t("relationships.requestInterrupted"), + t("relationships.configurationFailed"), + ), + configureInverseRelation.failedVariables + ? { + action: { + label: t("relationships.retrySavedChange"), + onClick: () => void retryInverseEditing(), + }, + } + : undefined, + ); + } + } + + async function retryInverseEditing() { + try { + await configureInverseRelation.retryFailed(); + } catch (caught) { + if (isSupersededRelationshipMutationError(caught)) return; + toast.error( + relationshipMutationErrorMessage( + caught, + t("relationships.requestInterrupted"), + t("relationships.configurationFailed"), + ), + { + action: { + label: t("relationships.retrySavedChange"), + onClick: () => void retryInverseEditing(), + }, + }, + ); + } + } const optionDragSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), @@ -1665,40 +1760,45 @@ export function PropertyManagementPopover({ /> - - - - {t("editor.properties.type")} - - {t(`editor.propertyTypes.${property.definition.type}`)} - - - - {CREATABLE_DOCUMENT_PROPERTY_TYPES.map((propertyType) => { - const TypeIcon = TYPE_ICONS[propertyType]; - const selected = property.definition.type === propertyType; - const disabled = typeIsLocked && !selected; - return ( - { - event.preventDefault(); - void updateType(propertyType); - }} - > - - - {t(`editor.propertyTypes.${propertyType}`)} - - {selected ? ( - - ) : null} - - ); - })} - - + {!canonicalRelation ? ( + + + + + {t("editor.properties.type")} + + + {t(`editor.propertyTypes.${property.definition.type}`)} + + + + {CREATABLE_DOCUMENT_PROPERTY_TYPES.map((propertyType) => { + const TypeIcon = TYPE_ICONS[propertyType]; + const selected = + property.definition.type === propertyType; + const disabled = typeIsLocked && !selected; + return ( + { + event.preventDefault(); + void updateType(propertyType); + }} + > + + + {t(`editor.propertyTypes.${propertyType}`)} + + {selected ? ( + + ) : null} + + ); + })} + + + ) : null} @@ -1903,17 +2003,31 @@ export function PropertyManagementPopover({ ) : null} + {canonicalRelation?.direction === "inverse" ? ( + event.preventDefault()} + onCheckedChange={(editable) => { + void updateInverseEditing(editable); + }} + > + {t("relationships.inverseEditable")} + + ) : null} - { - event.preventDefault(); - void duplicateProperty(); - }} - > - - {t("editor.properties.duplicateProperty")} - + {!canonicalRelation ? ( + { + event.preventDefault(); + void duplicateProperty(); + }} + > + + {t("editor.properties.duplicateProperty")} + + ) : null} - - - - - {t("editor.properties.deletePropertyQuestion")} - - - {t("editor.properties.deletePropertyDescriptionPrefix")} - - {property.definition.name} - - {t("editor.properties.deletePropertyDescriptionSuffix")} - - - {isOnlyBlocksField ? ( -
- {t("editor.properties.onlyBlocksPropertyWarning")} -
- ) : null} - - - {t("editor.properties.cancel")} - - void deleteProperty()} - > - {t("editor.properties.deleteProperty")} - - -
-
+ {canonicalRelation ? ( + setOpen(false)} + /> + ) : ( + + + + + {t("editor.properties.deletePropertyQuestion")} + + + {t("editor.properties.deletePropertyDescriptionPrefix")} + + {property.definition.name} + + {t("editor.properties.deletePropertyDescriptionSuffix")} + + + {isOnlyBlocksField ? ( +
+ {t("editor.properties.onlyBlocksPropertyWarning")} +
+ ) : null} + + + {t("editor.properties.cancel")} + + void deleteProperty()} + > + {t("editor.properties.deleteProperty")} + + +
+
+ )} ); } @@ -2175,6 +2301,15 @@ function PropertyValueEditor({ onDone: () => void; }) { const type = property.definition.type; + if (type === "relation") { + return ( + + ); + } if (type === "select" || type === "status" || type === "multi_select") { return ( (null); const [sourceHandoffClosing, setSourceHandoffClosing] = useState(false); const handledOpenRequestId = useRef(0); const [typeQuery, setTypeQuery] = useState(""); @@ -3362,6 +3498,8 @@ export function AddProperty({ string | null >(null); const [addPropertyError, setAddPropertyError] = useState(null); + const [relationConfigurationOpen, setRelationConfigurationOpen] = + useState(false); const isAddingProperty = configure.isPending || addSourceFieldProperty.isPending || @@ -3407,6 +3545,13 @@ export function AddProperty({ } async function add(type: DocumentPropertyType) { + if (type === "relation") { + setTypeQuery(""); + setAddPropertyError(null); + setOpen(false); + setRelationConfigurationOpen(true); + return; + } const label = t(`editor.propertyTypes.${type}`); setPendingPropertyType(type); setPendingSourceFieldId(null); @@ -3479,218 +3624,232 @@ export function AddProperty({ } return ( - { - if (nextOpen) { - setSourceHandoffClosing(false); - setOpen(true); - } else if (!isAddingProperty) { - closeAddPropertyPicker(); - } - }} - > - - + + - - {variant === "default" || variant === "header" - ? (label ?? t("editor.properties.addProperty")) - : null} - - - -
-
- - setTypeQuery(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter" && firstFilteredPropertyType) { - event.preventDefault(); - void add(firstFilteredPropertyType); - } else if (event.key === "Enter" && connectSourceMatches) { - event.preventDefault(); - connectSource(); - } - if (event.key === "Escape") { - event.preventDefault(); - closeAddPropertyPicker(); - } - }} - className="h-7 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0" - /> -
-
- {connectSourceMatches ? ( - - ) : null} - {sourceFieldGroups.map((group) => ( -
-
- {t("editor.properties.fromSource", { - name: group.source.sourceName, - })} -
- {group.fields.map((field) => { - const SourceFieldIcon = - TYPE_ICONS[ - propertyTypeForSourceFieldType(field.sourceFieldType) - ]; - return ( - - ); - })} -
- ))} - {filteredPropertyTypes.length === 0 && !connectSourceMatches ? ( -
- {t("editor.properties.noMatchingPropertyTypes")} -
- ) : null} - {filteredPropertyTypes.map((type) => { - const Icon = TYPE_ICONS[type]; - return ( +
+
+ + setTypeQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && firstFilteredPropertyType) { + event.preventDefault(); + void add(firstFilteredPropertyType); + } else if (event.key === "Enter" && connectSourceMatches) { + event.preventDefault(); + connectSource(); + } + if (event.key === "Escape") { + event.preventDefault(); + closeAddPropertyPicker(); + } + }} + className="h-7 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0" + /> +
+
+ {connectSourceMatches ? ( + ) : null} + {sourceFieldGroups.map((group) => ( +
+
+ {t("editor.properties.fromSource", { + name: group.source.sourceName, + })} +
+ {group.fields.map((field) => { + const SourceFieldIcon = + TYPE_ICONS[ + propertyTypeForSourceFieldType(field.sourceFieldType) + ]; + return ( + + ); })} - className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent" - disabled={isAddingProperty} - aria-busy={pendingPropertyType === type} - onPointerDownCapture={(event) => - activateAddPropertyItem(event, `type:${type}`, () => { - void add(type); - }) - } - onClick={(event) => - activateAddPropertyItem(event, `type:${type}`, () => { - void add(type); - }) - } - onKeyDown={(event) => - activateAddPropertyItemFromKeyboard( - event, - `type:${type}`, - () => { +
+ ))} + {filteredPropertyTypes.length === 0 && !connectSourceMatches ? ( +
+ {t("editor.properties.noMatchingPropertyTypes")} +
+ ) : null} + {filteredPropertyTypes.map((type) => { + const Icon = TYPE_ICONS[type]; + return ( + - ); - })} - {addPropertyError !== null ? ( -
- {t("editor.properties.addPropertyFailed")} - {addPropertyError ? ` ${addPropertyError}` : null} -
- ) : null} + {isComputedPropertyType(type) ? ( + + {t("editor.properties.computed")} + + ) : null} + + ); + })} + {addPropertyError !== null ? ( +
+ {t("editor.properties.addPropertyFailed")} + {addPropertyError ? ` ${addPropertyError}` : null} +
+ ) : null} +
-
- - + + + { + setRelationConfigurationOpen(nextOpen); + if (!nextOpen) { + requestAnimationFrame(() => triggerRef.current?.focus()); + } + }} + /> + ); } diff --git a/templates/content/app/components/editor/RelationPropertyConfigurationDialog.tsx b/templates/content/app/components/editor/RelationPropertyConfigurationDialog.tsx new file mode 100644 index 00000000000..bad1425a5a4 --- /dev/null +++ b/templates/content/app/components/editor/RelationPropertyConfigurationDialog.tsx @@ -0,0 +1,502 @@ +import { useT } from "@agent-native/core/client/i18n"; +import type { RelationshipTypeVersion } from "@shared/relationships"; +import { IconArrowRight, IconCheck, IconSearch } from "@tabler/icons-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { useContentDatabases } from "@/hooks/use-content-database"; +import { + contentRelationshipOperationId, + isSupersededRelationshipMutationError, + relationshipMutationErrorMessage, + useConfigureContentRelationProperty, + useContentRelationshipTypes, +} from "@/hooks/use-content-relationships"; +import { useRelationshipAppState } from "@/hooks/use-relationship-app-state"; +import { cn } from "@/lib/utils"; + +export function relationshipDirectionForDatabase( + version: RelationshipTypeVersion, + databaseId: string, +) { + if (version.sourceDatabaseId === databaseId) return "forward" as const; + if (version.targetDatabaseId === databaseId) return "inverse" as const; + return null; +} + +export function RelationPropertyConfigurationDialog({ + open, + ownerDatabaseId, + onOpenChange, + onCreated, +}: { + open: boolean; + ownerDatabaseId: string; + onOpenChange: (open: boolean) => void; + onCreated?: () => void; +}) { + const t = useT(); + const databases = useContentDatabases({ enabled: open }); + const types = useContentRelationshipTypes( + open ? { databaseId: ownerDatabaseId, limit: 100 } : null, + ); + const configure = useConfigureContentRelationProperty(); + const [mode, setMode] = useState<"new" | "existing">("new"); + const [databaseQuery, setDatabaseQuery] = useState(""); + const [activeDatabaseIndex, setActiveDatabaseIndex] = useState(0); + const [targetDatabaseId, setTargetDatabaseId] = useState(""); + const [forwardLabel, setForwardLabel] = useState(""); + const [inverseLabel, setInverseLabel] = useState(""); + const [forwardCardinality, setForwardCardinality] = useState<"one" | "many">( + "many", + ); + const [createInverse, setCreateInverse] = useState(false); + const [inverseEditable, setInverseEditable] = useState(false); + const [existingTypeId, setExistingTypeId] = useState(""); + const [error, setError] = useState(null); + const firstInputRef = useRef(null); + + useRelationshipAppState( + open + ? { + databaseId: ownerDatabaseId, + typeId: existingTypeId || undefined, + surface: "configuration", + } + : null, + ); + + useEffect(() => { + if (!open) return; + configure.clearFailedRequest(); + setError(null); + const frame = requestAnimationFrame(() => firstInputRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, [open]); + + useEffect(() => { + configure.clearFailedRequest(); + setError(null); + }, [ + mode, + targetDatabaseId, + forwardLabel, + inverseLabel, + forwardCardinality, + createInverse, + inverseEditable, + existingTypeId, + ]); + + const filteredDatabases = useMemo(() => { + const query = databaseQuery.trim().toLowerCase(); + return (databases.data?.databases ?? []).filter( + (database) => !query || database.title.toLowerCase().includes(query), + ); + }, [databaseQuery, databases.data?.databases]); + const existingTypes = types.data?.items ?? []; + const selectedExisting = existingTypes.find( + (item) => item.type.id === existingTypeId, + ); + const selectedExistingDirection = selectedExisting + ? relationshipDirectionForDatabase( + selectedExisting.version, + ownerDatabaseId, + ) + : null; + + async function submit() { + setError(null); + try { + if (mode === "new") { + const target = (databases.data?.databases ?? []).find( + (database) => database.databaseId === targetDatabaseId, + ); + if (!target || !forwardLabel.trim() || !inverseLabel.trim()) return; + await configure.mutateAsync({ + ownerDatabaseId, + alias: forwardLabel.trim(), + definition: { + kind: "new-local", + forwardLabel: forwardLabel.trim(), + inverseLabel: inverseLabel.trim(), + forwardCardinality, + sourceDatabaseId: ownerDatabaseId, + targetDatabaseId, + }, + inverseProjection: createInverse + ? { + ownerDatabaseId: targetDatabaseId, + alias: inverseLabel.trim(), + editable: inverseEditable, + } + : undefined, + operationId: contentRelationshipOperationId(), + }); + } else { + if (!selectedExisting || !selectedExistingDirection) return; + await configure.mutateAsync({ + ownerDatabaseId, + alias: + selectedExistingDirection === "forward" + ? selectedExisting.version.forwardLabel + : selectedExisting.version.inverseLabel, + definition: { + kind: "existing", + relationshipTypeId: selectedExisting.type.id, + direction: selectedExistingDirection, + }, + operationId: contentRelationshipOperationId(), + }); + } + finishConfiguration(); + } catch (caught) { + if (isSupersededRelationshipMutationError(caught)) return; + setError( + relationshipMutationErrorMessage( + caught, + t("relationships.requestInterrupted"), + t("relationships.configurationFailed"), + ), + ); + } + } + + function finishConfiguration() { + onOpenChange(false); + onCreated?.(); + } + + async function retrySubmit() { + setError(null); + try { + await configure.retryFailed(); + finishConfiguration(); + } catch (caught) { + if (isSupersededRelationshipMutationError(caught)) return; + setError( + relationshipMutationErrorMessage( + caught, + t("relationships.requestInterrupted"), + t("relationships.configurationFailed"), + ), + ); + } + } + + const newReady = + !!targetDatabaseId && !!forwardLabel.trim() && !!inverseLabel.trim(); + const ready = + mode === "new" + ? newReady + : !!selectedExisting && + !!selectedExistingDirection && + selectedExisting.capabilities.canConfigure; + + return ( + + + + {t("relationships.addRelation")} + +
+
+ {t("relationships.definition")} + {(["new", "existing"] as const).map((value) => ( + + ))} +
+ + {mode === "new" ? ( +
+
+ + setForwardLabel(event.target.value)} + /> +
+
+ + setInverseLabel(event.target.value)} + /> +
+
+ + {t("relationships.cardinality")} + +
+ {(["one", "many"] as const).map((value) => ( + + ))} +
+
+
+ +
+ + { + setDatabaseQuery(event.target.value); + setActiveDatabaseIndex(0); + }} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveDatabaseIndex((current) => + Math.max( + 0, + Math.min(filteredDatabases.length - 1, current + 1), + ), + ); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveDatabaseIndex((current) => + Math.max(0, current - 1), + ); + } else if (event.key === "Enter") { + const database = filteredDatabases[activeDatabaseIndex]; + if (database) { + event.preventDefault(); + setTargetDatabaseId(database.databaseId); + } + } + }} + /> +
+
+ {databases.isLoading ? ( +
+ + +
+ ) : databases.isError ? ( + + ) : filteredDatabases.length === 0 ? ( +
+ {t("relationships.noDatabases")} +
+ ) : ( + filteredDatabases.map((database, index) => ( + + )) + )} +
+
+
+
+ + { + setCreateInverse(checked); + if (!checked) setInverseEditable(false); + }} + /> +
+ {createInverse ? ( +
+ + +
+ ) : null} +
+
+ ) : ( +
+ {types.isLoading ? ( +
+ + +
+ ) : types.isError ? ( + + ) : existingTypes.length === 0 ? ( +
+ {t("relationships.noExistingRelationships")} +
+ ) : ( + existingTypes.map((item) => { + const direction = relationshipDirectionForDatabase( + item.version, + ownerDatabaseId, + ); + return ( + + ); + }) + )} +
+ )} + + {error ? ( +
+ {error} + {configure.failedVariables ? ( + + ) : null} +
+ ) : null} +
+ + + + +
+
+ ); +} diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 1c1a0873cd4..a55b353b8c4 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -235,6 +235,10 @@ import { builderBodyHydrationRetryDelayMs, shouldPumpBuilderBodyHydration, } from "../builder-body-hydration-pump"; +import { + RelationBulkValueEditor, + RelationValueSummary, +} from "../ContentRelationships"; import { BuilderSourceReviewDialog, type BuilderReviewPublicationTransitions, @@ -15495,13 +15499,16 @@ function DatabaseBulkEditPopover({ Edit - +
Edit {selectedCount} selected row{selectedCount === 1 ? "" : "s"}
-
-
+
+
{properties.map((property) => { const Icon = TYPE_ICONS[property.definition.type]; const selected = @@ -15561,6 +15568,17 @@ function DatabaseBulkPropertyValueEditor({ }) { const type = property.definition.type; + if (type === "relation") { + return ( + + ); + } + if (type === "checkbox") { return (
@@ -18115,7 +18133,19 @@ function DatabaseTableRow({ "text-transparent", )} > - {databaseTableCellDisplayValue(itemProperty, item, wrapCells)} + {itemProperty.definition.type === "relation" ? ( + + ) : ( + databaseTableCellDisplayValue(itemProperty, item, wrapCells) + )}
); const isEditableCheckbox = diff --git a/templates/content/app/hooks/use-content-relationships.test.ts b/templates/content/app/hooks/use-content-relationships.test.ts new file mode 100644 index 00000000000..f70c8f5f9b8 --- /dev/null +++ b/templates/content/app/hooks/use-content-relationships.test.ts @@ -0,0 +1,167 @@ +import type { DocumentProperty } from "@shared/api"; +import { describe, expect, it } from "vitest"; + +import { + canonicalRelationOptions, + contentRelationshipOperationId, + createRelationshipMutationRetry, + relationshipMutationErrorMessage, +} from "./use-content-relationships"; + +function property(relation: unknown): Pick { + return { + definition: { + options: { relation }, + } as DocumentProperty["definition"], + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, reject, resolve }; +} + +describe("content relationship hooks", () => { + it("recognizes only canonical relation projection metadata", () => { + expect( + canonicalRelationOptions( + property({ + databaseId: "people", + relationshipTypeId: "contributors", + direction: "forward", + editable: true, + }), + ), + ).toEqual({ + databaseId: "people", + relationshipTypeId: "contributors", + direction: "forward", + editable: true, + }); + expect(canonicalRelationOptions(property({ databaseId: "people" }))).toBe( + null, + ); + }); + + it("creates a distinct operation id for each committed intent", () => { + expect(contentRelationshipOperationId()).not.toBe( + contentRelationshipOperationId(), + ); + }); + + it("replays the exact failed request while a new intent replaces it", async () => { + type Request = { + operationId: string; + changes: Array<{ observationToken: string }>; + }; + const requests: Request[] = []; + let fail = true; + const retry = createRelationshipMutationRetry< + { revisionId: string }, + Request + >(async (request) => { + requests.push(request); + if (fail) { + fail = false; + throw new TypeError("Failed to fetch"); + } + return { revisionId: request.operationId }; + }); + const interrupted = { + operationId: "same-operation", + changes: [{ observationToken: "frozen-observation" }], + }; + + await expect(retry.run(interrupted)).rejects.toThrow("Failed to fetch"); + await expect(retry.retry()).resolves.toEqual({ + revisionId: "same-operation", + }); + expect(requests[1]).toBe(requests[0]); + + const newIntent = { + operationId: "new-operation", + changes: [{ observationToken: "new-observation" }], + }; + await retry.run(newIntent); + expect(requests[2]).toBe(newIntent); + expect(requests[2].operationId).not.toBe(requests[0].operationId); + }); + + it("keeps authored Action failures and replaces transport framing", () => { + const denied = Object.assign(new Error("Action mutate failed: denied"), { + actionMessage: "You cannot edit this relationship.", + }); + expect( + relationshipMutationErrorMessage( + denied, + "Request interrupted.", + "Update failed.", + ), + ).toBe("You cannot edit this relationship."); + expect( + relationshipMutationErrorMessage( + new TypeError( + "Action mutate-content-relationships failed: Failed to fetch", + ), + "Request interrupted.", + "Update failed.", + ), + ).toBe("Request interrupted."); + }); + + it("does not resurrect a cleared request when an older write fails", async () => { + const pending = deferred(); + const retry = createRelationshipMutationRetry(() => pending.promise); + const oldRequest = retry.run({ operationId: "old" }); + + retry.clear(); + pending.reject(new TypeError("Failed to fetch")); + + await expect(oldRequest).rejects.toMatchObject({ + name: "SupersededRelationshipMutationError", + }); + expect(retry.failedVariables()).toBeNull(); + expect(() => retry.retry()).toThrow( + "No failed relationship request is available.", + ); + }); + + it("keeps the newer failure when an older write settles afterward", async () => { + const oldPending = deferred(); + const newPending = deferred(); + const retry = createRelationshipMutationRetry( + (request: { operationId: string }) => + request.operationId === "old" ? oldPending.promise : newPending.promise, + ); + const oldRequest = retry.run({ operationId: "old" }); + const newRequest = retry.run({ operationId: "new" }); + + newPending.reject(new TypeError("Failed to fetch")); + await expect(newRequest).rejects.toThrow("Failed to fetch"); + oldPending.resolve("old receipt"); + await expect(oldRequest).rejects.toMatchObject({ + name: "SupersededRelationshipMutationError", + }); + + expect(retry.failedVariables()).toEqual({ operationId: "new" }); + }); + + it("rejects an obsolete success after clear without restoring retry state", async () => { + const pending = deferred(); + const retry = createRelationshipMutationRetry(() => pending.promise); + const oldRequest = retry.run({ operationId: "old" }); + + retry.clear(); + pending.resolve("old receipt"); + + await expect(oldRequest).rejects.toMatchObject({ + name: "SupersededRelationshipMutationError", + }); + expect(retry.failedVariables()).toBeNull(); + }); +}); diff --git a/templates/content/app/hooks/use-content-relationships.ts b/templates/content/app/hooks/use-content-relationships.ts new file mode 100644 index 00000000000..2bc7e8e63f9 --- /dev/null +++ b/templates/content/app/hooks/use-content-relationships.ts @@ -0,0 +1,259 @@ +import { + actionErrorMessage, + useActionMutation, + useActionQuery, +} from "@agent-native/core/client/hooks"; +import type { DocumentProperty } from "@shared/api"; +import { + canonicalRelationOptionsSchema, + type CanonicalRelationOptions, + type ConfigureContentRelationPropertyInput, + type ConfigureContentRelationPropertyResult, + type ListContentRelationCandidatesInput, + type ListContentRelationCandidatesResult, + type ListContentRelationshipHistoryInput, + type ListContentRelationshipHistoryResult, + type ListContentRelationshipsInput, + type ListContentRelationshipsResult, + type ListContentRelationshipTypesInput, + type ListContentRelationshipTypesResult, + type MutateContentRelationshipsInput, + type MutateContentRelationshipsResult, + type PrepareContentRelationshipRemovalInput, + type PrepareContentRelationshipRemovalResult, + type RemoveContentRelationPropertyInput, + type RemoveContentRelationPropertyResult, + type UndoContentRelationshipRevisionInput, + type UndoContentRelationshipRevisionResult, +} from "@shared/relationships"; +import { useQueryClient } from "@tanstack/react-query"; +import { useRef, useState } from "react"; + +const relationshipActionNames = [ + "list-content-relationship-types", + "list-content-relation-candidates", + "list-content-relationships", + "list-content-relationship-history", +] as const; + +export function contentRelationshipOperationId() { + return globalThis.crypto.randomUUID(); +} + +export function relationshipMutationErrorMessage( + error: unknown, + interruptedMessage: string, + fallbackMessage: string, +) { + const actionMessage = actionErrorMessage(error); + if (actionMessage) return actionMessage; + if (error instanceof Error) { + return /^Action .+ failed: /.test(error.message) + ? interruptedMessage + : error.message; + } + return fallbackMessage; +} + +export class SupersededRelationshipMutationError extends Error { + constructor() { + super("A newer relationship intent superseded this request."); + this.name = "SupersededRelationshipMutationError"; + } +} + +export function isSupersededRelationshipMutationError(error: unknown) { + return error instanceof SupersededRelationshipMutationError; +} + +export function createRelationshipMutationRetry( + execute: (variables: TVariables) => Promise, +) { + let failedVariables: TVariables | null = null; + let intentGeneration = 0; + + async function executeAndRemember(variables: TVariables, generation: number) { + let result: TData; + try { + result = await execute(variables); + } catch (error) { + if (intentGeneration !== generation) { + throw new SupersededRelationshipMutationError(); + } + failedVariables = variables; + throw error; + } + if (intentGeneration !== generation) { + throw new SupersededRelationshipMutationError(); + } + failedVariables = null; + return result; + } + + return { + run(variables: TVariables) { + intentGeneration += 1; + failedVariables = null; + return executeAndRemember(variables, intentGeneration); + }, + retry() { + if (failedVariables === null) { + throw new Error("No failed relationship request is available."); + } + return executeAndRemember(failedVariables, intentGeneration); + }, + clear() { + intentGeneration += 1; + failedVariables = null; + }, + failedVariables() { + return failedVariables; + }, + }; +} + +export function canonicalRelationOptions( + property: Pick, +): CanonicalRelationOptions | null { + const result = canonicalRelationOptionsSchema.safeParse( + property.definition.options.relation, + ); + return result.success ? result.data : null; +} + +function useRelationshipMutation(name: string) { + const queryClient = useQueryClient(); + const mutation = useActionMutation(name, { + skipActionQueryInvalidation: true, + onSuccess: async () => { + await Promise.all( + [ + ...relationshipActionNames, + "get-content-database", + "list-document-properties", + ].map((actionName) => + queryClient.invalidateQueries({ + queryKey: ["action", actionName], + }), + ), + ); + }, + }); + const executeRef = useRef( + mutation.mutateAsync as (variables: TVariables) => Promise, + ); + executeRef.current = mutation.mutateAsync as ( + variables: TVariables, + ) => Promise; + const retryRef = useRef + > | null>(null); + const [, setRetryVersion] = useState(0); + retryRef.current ??= createRelationshipMutationRetry((variables) => + executeRef.current(variables), + ); + + async function mutateAsync(variables: TVariables) { + try { + return await retryRef.current!.run(variables); + } finally { + setRetryVersion((version) => version + 1); + } + } + + async function retryFailed() { + try { + return await retryRef.current!.retry(); + } finally { + setRetryVersion((version) => version + 1); + } + } + + function clearFailedRequest() { + retryRef.current!.clear(); + setRetryVersion((version) => version + 1); + } + + return { + ...mutation, + mutateAsync, + retryFailed, + clearFailedRequest, + failedVariables: retryRef.current.failedVariables(), + }; +} + +export function useContentRelationshipTypes( + input: ListContentRelationshipTypesInput | null, +) { + return useActionQuery( + "list-content-relationship-types", + input ?? undefined, + { enabled: !!input, placeholderData: (previous) => previous }, + ); +} + +export function useContentRelationCandidates( + input: ListContentRelationCandidatesInput | null, +) { + return useActionQuery( + "list-content-relation-candidates", + input ?? undefined, + { enabled: !!input, placeholderData: (previous) => previous }, + ); +} + +export function useContentRelationships( + input: ListContentRelationshipsInput | null, +) { + return useActionQuery( + "list-content-relationships", + input ?? undefined, + { enabled: !!input, placeholderData: (previous) => previous }, + ); +} + +export function useContentRelationshipHistory( + input: ListContentRelationshipHistoryInput | null, +) { + return useActionQuery( + "list-content-relationship-history", + input ?? undefined, + { enabled: !!input, placeholderData: (previous) => previous }, + ); +} + +export function useConfigureContentRelationProperty() { + return useRelationshipMutation< + ConfigureContentRelationPropertyResult, + ConfigureContentRelationPropertyInput + >("configure-content-relation-property"); +} + +export function useMutateContentRelationships() { + return useRelationshipMutation< + MutateContentRelationshipsResult, + MutateContentRelationshipsInput + >("mutate-content-relationships"); +} + +export function usePrepareContentRelationshipRemoval() { + return useRelationshipMutation< + PrepareContentRelationshipRemovalResult, + PrepareContentRelationshipRemovalInput + >("prepare-content-relationship-removal"); +} + +export function useRemoveContentRelationProperty() { + return useRelationshipMutation< + RemoveContentRelationPropertyResult, + RemoveContentRelationPropertyInput + >("remove-content-relation-property"); +} + +export function useUndoContentRelationshipRevision() { + return useRelationshipMutation< + UndoContentRelationshipRevisionResult, + UndoContentRelationshipRevisionInput + >("undo-content-relationship-revision"); +} diff --git a/templates/content/app/hooks/use-relationship-app-state.ts b/templates/content/app/hooks/use-relationship-app-state.ts new file mode 100644 index 00000000000..47f2b7a59c6 --- /dev/null +++ b/templates/content/app/hooks/use-relationship-app-state.ts @@ -0,0 +1,83 @@ +import { + getBrowserTabId, + setClientAppState, +} from "@agent-native/core/client/hooks"; +import { appStateKeyForBrowserTab } from "@shared/app-state-tabs"; +import { useEffect, useRef } from "react"; + +export const CONTENT_RELATIONSHIP_CONTEXT_KEY = + "content-relationship-context" as const; + +export interface ContentRelationshipContext { + pageId?: string; + propertyId?: string; + typeId?: string; + databaseId?: string; + selectedPageIds?: string[]; + surface: "picker" | "connections" | "bulk" | "history" | "configuration"; +} + +interface RelationshipContextOwner { + context: ContentRelationshipContext; + sequence: number; +} + +const relationshipContextOwners = new Map< + string, + Map +>(); +let relationshipContextSequence = 0; + +function latestRelationshipContext( + owners: Map, +) { + return Array.from(owners.values()).reduce( + (latest, candidate) => + !latest || candidate.sequence > latest.sequence ? candidate : latest, + null, + ); +} + +/** + * Publishes only navigation and selection identifiers. Relationship data stays + * in the canonical Action reads and never enters application state. + */ +export function useRelationshipAppState( + context: ContentRelationshipContext | null, +) { + const ownerRef = useRef(Symbol("content-relationship-context")); + const serialized = context ? JSON.stringify(context) : null; + + useEffect(() => { + if (!serialized) return; + const owner = ownerRef.current; + const key = appStateKeyForBrowserTab( + CONTENT_RELATIONSHIP_CONTEXT_KEY, + getBrowserTabId(), + ); + const context = JSON.parse(serialized) as ContentRelationshipContext; + const owners = + relationshipContextOwners.get(key) ?? + new Map(); + relationshipContextOwners.set(key, owners); + const entry = { context, sequence: ++relationshipContextSequence }; + owners.set(owner, entry); + void setClientAppState(key, context, { + keepalive: true, + requestSource: "content-relationship-ui", + }); + + return () => { + if (owners.get(owner) !== entry) return; + const wasLatest = latestRelationshipContext(owners) === entry; + owners.delete(owner); + if (!wasLatest) return; + const next = latestRelationshipContext(owners); + if (owners.size === 0) relationshipContextOwners.delete(key); + void setClientAppState(key, next?.context ?? null, { + keepalive: true, + requestSource: "content-relationship-ui", + }); + }; + }, [serialized]); +} diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 18ac41386c5..0003123b73b 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -3134,6 +3134,99 @@ const localFilesMessages = { sidebar: "Sidebar", }; +const relationshipMessages = { + addRelation: "Add relation", + adding: "Adding…", + definition: "Relationship definition", + newRelationship: "New relationship", + existingRelationship: "Existing relationship", + forwardLabel: "Property name", + forwardLabelPlaceholder: "Contributors", + inverseLabel: "Inverse name", + inverseLabelPlaceholder: "Deliverables", + cardinality: "Pages per row", + onePage: "One page", + manyPages: "Many pages", + targetDatabase: "Target database", + searchDatabases: "Search databases", + retryDatabases: "Retry databases", + noDatabases: "No matching databases", + createInverseProperty: "Create inverse property", + inverseEditable: "Allow editing from the inverse property", + retryRelationships: "Retry relationships", + noExistingRelationships: "No existing relationships", + configurationFailed: "The relation could not be added.", + valueUnavailable: "Unavailable", + empty: "Empty", + moreCount: "+{{count}} more", + searchPages: "Search pages", + searchForProperty: "Search pages for {{name}}", + noMatchingPages: "No matching pages", + loadFailed: "Relationships could not be loaded.", + tryAgain: "Try again", + routeUnavailable: "This relationship is read-only from here.", + refreshBeforeReplacing: "Refresh before replacing this relationship.", + updateFailed: "The relationship could not be updated.", + requestInterrupted: + "The request was interrupted. Retry the same change to confirm whether it was saved.", + retrySavedChange: "Retry same change", + legacyUnsupported: "This legacy relation cannot be edited here yet.", + done: "Done", + cancel: "Cancel", + connections: "Connections", + noConnections: "No connections", + retryConnections: "Retry connections", + removeConnection: "Remove connection to {{name}}", + connectionRemoved: "Connection removed", + removeFailed: "The connection could not be removed.", + history: "History", + noHistory: "No relationship history", + retryHistory: "Retry history", + historyDetailsUnavailable: + "Relationship history details are unavailable. Refresh history.", + historyChangeAdded: "Added", + historyChangeRemoved: "Removed", + historyChangeReplaced: "Replaced", + historyChangeRestored: "Restored", + historyPreviousTarget: "Previously {{name}}", + historyActor: "{{kind}}: {{name}}", + historyActorPerson: "Person", + historyActorAgent: "Agent", + historyActorAutomation: "Automation", + historyActorProgrammatic: "Programmatic", + historyOrigin: "Origin: {{origin}}", + historyAuthorizedBy: "Authorized by {{principal}}", + undo: "Undo", + undoChange: "Undo change", + changeUndone: "Change undone", + undoFailed: "This change could not be undone.", + addToSelected: "Add", + removeFromSelected: "Remove", + noSharedRelationships: "No matching relationships on the selected rows", + apply: "Apply", + bulkAdded: "Added relationships to {{count}} rows", + bulkRemoved: "Removed relationships from {{count}} rows", + bulkFailed: "No relationships changed.", + bulkInverseMaxOne: + "A one-page relationship cannot assign one source page to multiple selected rows.", + removeProperty: "Remove relation property", + removePropertyNamed: "Remove {{name}} from this database.", + relationshipsPreserved: "Relationships are preserved by default", + selectRelationshipsToRemove: "Also remove selected relationships", + filterRelationships: "Filter relationships", + noMatchingRelationships: "No matching relationships", + removalPreviewFailed: "The relationship preview could not be loaded.", + removePropertyOnly: "Remove property", + removePropertyAndSelected: "Remove property and {{count}} selected", + propertyRemoved: "Relation property removed", + removePropertyFailed: "The relation property could not be removed.", + states: { + active: "Active", + suspended: "Suspended", + inactive: "Inactive", + }, +}; + const enUS = { creativeContext: creativeContextMessagesByLocale["en-US"], root: { @@ -3167,6 +3260,7 @@ const enUS = { agent: "Agent", settings: "Settings", }, + relationships: relationshipMessages, landing: { previousPageUnavailable: "Your previous page is no longer available, so we opened Welcome.", @@ -3589,6 +3683,671 @@ type PartialMessages = { : Partial; }; +const relationshipMessagesByLocale = { + "zh-CN": { + ...relationshipMessages, + addRelation: "添加关联", + adding: "正在添加…", + definition: "关联定义", + newRelationship: "新建关联", + existingRelationship: "现有关联", + forwardLabel: "属性名称", + inverseLabel: "反向名称", + cardinality: "每行页面数", + onePage: "一个页面", + manyPages: "多个页面", + targetDatabase: "目标数据库", + searchDatabases: "搜索数据库", + noDatabases: "没有匹配的数据库", + createInverseProperty: "创建反向属性", + inverseEditable: "允许从反向属性编辑", + searchPages: "搜索页面", + noMatchingPages: "没有匹配的页面", + done: "完成", + cancel: "取消", + connections: "连接", + noConnections: "没有连接", + history: "历史记录", + noHistory: "没有关联历史记录", + undo: "撤销", + apply: "应用", + addToSelected: "添加", + removeFromSelected: "移除", + removeProperty: "移除关联属性", + relationshipsPreserved: "默认保留关联", + selectRelationshipsToRemove: "同时移除所选关联", + removePropertyOnly: "仅移除属性", + removePropertyAndSelected: "移除属性和 {{count}} 个所选关联", + retryDatabases: "重试加载数据库", + retryRelationships: "重试加载关联", + noExistingRelationships: "没有现有关联", + configurationFailed: "无法添加关联。", + loadFailed: "无法加载关联。", + tryAgain: "重试", + routeUnavailable: "此处的关联为只读。", + refreshBeforeReplacing: "请刷新后再替换此关联。", + updateFailed: "无法更新关联。", + requestInterrupted: "请求已中断。请重试相同的更改,以确认是否已保存。", + retrySavedChange: "重试相同更改", + legacyUnsupported: "尚无法在此处编辑此旧版关联。", + retryConnections: "重试加载连接", + connectionRemoved: "已移除连接", + removeFailed: "无法移除连接。", + retryHistory: "重试加载历史记录", + historyDetailsUnavailable: "关联历史记录详情不可用。请重新加载历史记录。", + undoChange: "撤销更改", + changeUndone: "已撤销更改", + undoFailed: "无法撤销此更改。", + noSharedRelationships: "所选行中没有匹配的关联", + bulkFailed: "未更改任何关联。", + bulkInverseMaxOne: "单页面关联无法将一个源页面分配给多个所选行。", + filterRelationships: "筛选关联", + noMatchingRelationships: "没有匹配的关联", + removalPreviewFailed: "无法加载关联预览。", + propertyRemoved: "已移除关联属性", + removePropertyFailed: "无法移除关联属性。", + states: { active: "有效", suspended: "已暂停", inactive: "无效" }, + }, + "zh-TW": { + ...relationshipMessages, + addRelation: "新增關聯", + adding: "正在新增…", + definition: "關聯定義", + newRelationship: "新關聯", + existingRelationship: "現有關聯", + forwardLabel: "屬性名稱", + inverseLabel: "反向名稱", + cardinality: "每列頁面數", + onePage: "一個頁面", + manyPages: "多個頁面", + targetDatabase: "目標資料庫", + searchDatabases: "搜尋資料庫", + noDatabases: "沒有相符的資料庫", + createInverseProperty: "建立反向屬性", + inverseEditable: "允許從反向屬性編輯", + searchPages: "搜尋頁面", + noMatchingPages: "沒有相符的頁面", + done: "完成", + cancel: "取消", + connections: "連結", + noConnections: "沒有連結", + history: "歷史記錄", + noHistory: "沒有關聯歷史記錄", + historyDetailsUnavailable: + "無法使用關聯歷史記錄詳細資料。請重新載入歷史記錄。", + historyChangeAdded: "已新增", + historyChangeRemoved: "已移除", + historyChangeReplaced: "已取代", + historyChangeRestored: "已還原", + historyPreviousTarget: "先前為 {{name}}", + historyActor: "{{kind}}:{{name}}", + historyActorPerson: "使用者", + historyActorAgent: "代理", + historyActorAutomation: "自動化", + historyActorProgrammatic: "程式呼叫", + historyOrigin: "來源:{{origin}}", + historyAuthorizedBy: "授權者:{{principal}}", + undo: "復原", + apply: "套用", + addToSelected: "新增", + removeFromSelected: "移除", + removeProperty: "移除關聯屬性", + relationshipsPreserved: "預設會保留關聯", + selectRelationshipsToRemove: "同時移除所選關聯", + removePropertyOnly: "僅移除屬性", + removePropertyAndSelected: "移除屬性和 {{count}} 個所選關聯", + states: { active: "有效", suspended: "已暫停", inactive: "無效" }, + }, + "es-ES": { + ...relationshipMessages, + addRelation: "Añadir relación", + adding: "Añadiendo…", + definition: "Definición de la relación", + newRelationship: "Nueva relación", + existingRelationship: "Relación existente", + forwardLabel: "Nombre de la propiedad", + inverseLabel: "Nombre inverso", + cardinality: "Páginas por fila", + onePage: "Una página", + manyPages: "Varias páginas", + targetDatabase: "Base de datos de destino", + searchDatabases: "Buscar bases de datos", + noDatabases: "No hay bases de datos coincidentes", + createInverseProperty: "Crear propiedad inversa", + inverseEditable: "Permitir editar desde la propiedad inversa", + searchPages: "Buscar páginas", + noMatchingPages: "No hay páginas coincidentes", + done: "Listo", + cancel: "Cancelar", + connections: "Conexiones", + noConnections: "Sin conexiones", + history: "Historial", + noHistory: "Sin historial de relaciones", + undo: "Deshacer", + apply: "Aplicar", + addToSelected: "Añadir", + removeFromSelected: "Quitar", + removeProperty: "Quitar propiedad de relación", + relationshipsPreserved: + "Las relaciones se conservan de forma predeterminada", + selectRelationshipsToRemove: "Quitar también las relaciones seleccionadas", + removePropertyOnly: "Quitar propiedad", + removePropertyAndSelected: "Quitar propiedad y {{count}} seleccionadas", + retryDatabases: "Reintentar bases de datos", + retryRelationships: "Reintentar relaciones", + noExistingRelationships: "No hay relaciones existentes", + configurationFailed: "No se pudo añadir la relación.", + loadFailed: "No se pudieron cargar las relaciones.", + tryAgain: "Intentar de nuevo", + routeUnavailable: "Esta relación es de solo lectura desde aquí.", + refreshBeforeReplacing: "Actualiza antes de reemplazar esta relación.", + updateFailed: "No se pudo actualizar la relación.", + requestInterrupted: + "La solicitud se interrumpió. Reintenta el mismo cambio para confirmar si se guardó.", + retrySavedChange: "Reintentar el mismo cambio", + legacyUnsupported: "Esta relación heredada aún no se puede editar aquí.", + retryConnections: "Reintentar conexiones", + connectionRemoved: "Conexión eliminada", + removeFailed: "No se pudo eliminar la conexión.", + retryHistory: "Reintentar historial", + historyDetailsUnavailable: + "Los detalles del historial de relaciones no están disponibles. Vuelve a cargar el historial.", + undoChange: "Deshacer cambio", + changeUndone: "Cambio deshecho", + undoFailed: "No se pudo deshacer este cambio.", + noSharedRelationships: + "No hay relaciones coincidentes en las filas seleccionadas", + bulkFailed: "No se modificó ninguna relación.", + bulkInverseMaxOne: + "Una relación de una página no puede asignar una página de origen a varias filas seleccionadas.", + filterRelationships: "Filtrar relaciones", + noMatchingRelationships: "No hay relaciones coincidentes", + removalPreviewFailed: "No se pudo cargar la vista previa de relaciones.", + propertyRemoved: "Propiedad de relación eliminada", + removePropertyFailed: "No se pudo eliminar la propiedad de relación.", + states: { active: "Activa", suspended: "Suspendida", inactive: "Inactiva" }, + }, + "fr-FR": { + ...relationshipMessages, + addRelation: "Ajouter une relation", + adding: "Ajout…", + definition: "Définition de la relation", + newRelationship: "Nouvelle relation", + existingRelationship: "Relation existante", + forwardLabel: "Nom de la propriété", + inverseLabel: "Nom inverse", + cardinality: "Pages par ligne", + onePage: "Une page", + manyPages: "Plusieurs pages", + targetDatabase: "Base de données cible", + searchDatabases: "Rechercher des bases de données", + noDatabases: "Aucune base de données correspondante", + createInverseProperty: "Créer la propriété inverse", + inverseEditable: "Autoriser la modification depuis la propriété inverse", + searchPages: "Rechercher des pages", + noMatchingPages: "Aucune page correspondante", + done: "Terminé", + cancel: "Annuler", + connections: "Connexions", + noConnections: "Aucune connexion", + history: "Historique", + noHistory: "Aucun historique de relation", + undo: "Annuler", + apply: "Appliquer", + addToSelected: "Ajouter", + removeFromSelected: "Supprimer", + removeProperty: "Supprimer la propriété de relation", + relationshipsPreserved: "Les relations sont conservées par défaut", + selectRelationshipsToRemove: "Supprimer aussi les relations sélectionnées", + removePropertyOnly: "Supprimer la propriété", + removePropertyAndSelected: + "Supprimer la propriété et {{count}} sélectionnées", + retryDatabases: "Réessayer les bases de données", + retryRelationships: "Réessayer les relations", + noExistingRelationships: "Aucune relation existante", + configurationFailed: "Impossible d’ajouter la relation.", + loadFailed: "Impossible de charger les relations.", + tryAgain: "Réessayer", + routeUnavailable: + "Cette relation est en lecture seule depuis cet emplacement.", + refreshBeforeReplacing: "Actualisez avant de remplacer cette relation.", + updateFailed: "Impossible de mettre à jour la relation.", + requestInterrupted: + "La requête a été interrompue. Réessayez la même modification pour confirmer son enregistrement.", + retrySavedChange: "Réessayer la même modification", + legacyUnsupported: + "Cette relation héritée ne peut pas encore être modifiée ici.", + retryConnections: "Réessayer les connexions", + connectionRemoved: "Connexion supprimée", + removeFailed: "Impossible de supprimer la connexion.", + retryHistory: "Réessayer l’historique", + historyDetailsUnavailable: + "Les détails de l’historique des relations ne sont pas disponibles. Rechargez l’historique.", + undoChange: "Annuler la modification", + changeUndone: "Modification annulée", + undoFailed: "Impossible d’annuler cette modification.", + noSharedRelationships: + "Aucune relation correspondante dans les lignes sélectionnées", + bulkFailed: "Aucune relation n’a été modifiée.", + bulkInverseMaxOne: + "Une relation limitée à une page ne peut pas attribuer une page source à plusieurs lignes sélectionnées.", + filterRelationships: "Filtrer les relations", + noMatchingRelationships: "Aucune relation correspondante", + removalPreviewFailed: "Impossible de charger l’aperçu des relations.", + propertyRemoved: "Propriété de relation supprimée", + removePropertyFailed: "Impossible de supprimer la propriété de relation.", + states: { active: "Active", suspended: "Suspendue", inactive: "Inactive" }, + }, + "de-DE": { + ...relationshipMessages, + addRelation: "Beziehung hinzufügen", + adding: "Wird hinzugefügt…", + definition: "Beziehungsdefinition", + newRelationship: "Neue Beziehung", + existingRelationship: "Bestehende Beziehung", + forwardLabel: "Eigenschaftsname", + inverseLabel: "Umgekehrter Name", + cardinality: "Seiten pro Zeile", + onePage: "Eine Seite", + manyPages: "Mehrere Seiten", + targetDatabase: "Zieldatenbank", + searchDatabases: "Datenbanken suchen", + noDatabases: "Keine passenden Datenbanken", + createInverseProperty: "Umgekehrte Eigenschaft erstellen", + inverseEditable: "Bearbeitung über die umgekehrte Eigenschaft erlauben", + searchPages: "Seiten suchen", + noMatchingPages: "Keine passenden Seiten", + done: "Fertig", + cancel: "Abbrechen", + connections: "Verbindungen", + noConnections: "Keine Verbindungen", + history: "Verlauf", + noHistory: "Kein Beziehungsverlauf", + undo: "Rückgängig", + apply: "Anwenden", + addToSelected: "Hinzufügen", + removeFromSelected: "Entfernen", + removeProperty: "Beziehungseigenschaft entfernen", + relationshipsPreserved: "Beziehungen bleiben standardmäßig erhalten", + selectRelationshipsToRemove: "Ausgewählte Beziehungen ebenfalls entfernen", + removePropertyOnly: "Eigenschaft entfernen", + removePropertyAndSelected: + "Eigenschaft und {{count}} ausgewählte entfernen", + retryDatabases: "Datenbanken erneut laden", + retryRelationships: "Beziehungen erneut laden", + noExistingRelationships: "Keine bestehenden Beziehungen", + configurationFailed: "Die Beziehung konnte nicht hinzugefügt werden.", + loadFailed: "Beziehungen konnten nicht geladen werden.", + tryAgain: "Erneut versuchen", + routeUnavailable: "Diese Beziehung ist hier schreibgeschützt.", + refreshBeforeReplacing: "Vor dem Ersetzen dieser Beziehung neu laden.", + updateFailed: "Die Beziehung konnte nicht aktualisiert werden.", + requestInterrupted: + "Die Anfrage wurde unterbrochen. Wiederholen Sie dieselbe Änderung, um zu prüfen, ob sie gespeichert wurde.", + retrySavedChange: "Dieselbe Änderung wiederholen", + legacyUnsupported: + "Diese ältere Beziehung kann hier noch nicht bearbeitet werden.", + retryConnections: "Verbindungen erneut laden", + connectionRemoved: "Verbindung entfernt", + removeFailed: "Die Verbindung konnte nicht entfernt werden.", + retryHistory: "Verlauf erneut laden", + historyDetailsUnavailable: + "Die Details des Beziehungsverlaufs sind nicht verfügbar. Laden Sie den Verlauf neu.", + undoChange: "Änderung rückgängig machen", + changeUndone: "Änderung rückgängig gemacht", + undoFailed: "Diese Änderung konnte nicht rückgängig gemacht werden.", + noSharedRelationships: + "Keine passenden Beziehungen in den ausgewählten Zeilen", + bulkFailed: "Keine Beziehungen geändert.", + bulkInverseMaxOne: + "Bei einer Beziehung mit einer Seite kann eine Quellseite nicht mehreren ausgewählten Zeilen zugewiesen werden.", + filterRelationships: "Beziehungen filtern", + noMatchingRelationships: "Keine passenden Beziehungen", + removalPreviewFailed: "Die Beziehungsvorschau konnte nicht geladen werden.", + propertyRemoved: "Beziehungseigenschaft entfernt", + removePropertyFailed: + "Die Beziehungseigenschaft konnte nicht entfernt werden.", + states: { active: "Aktiv", suspended: "Pausiert", inactive: "Inaktiv" }, + }, + "ja-JP": { + ...relationshipMessages, + addRelation: "リレーションを追加", + adding: "追加中…", + definition: "リレーション定義", + newRelationship: "新しいリレーション", + existingRelationship: "既存のリレーション", + forwardLabel: "プロパティ名", + inverseLabel: "逆方向の名前", + cardinality: "行ごとのページ数", + onePage: "1 ページ", + manyPages: "複数ページ", + targetDatabase: "対象データベース", + searchDatabases: "データベースを検索", + noDatabases: "一致するデータベースはありません", + createInverseProperty: "逆方向プロパティを作成", + inverseEditable: "逆方向プロパティからの編集を許可", + searchPages: "ページを検索", + noMatchingPages: "一致するページはありません", + done: "完了", + cancel: "キャンセル", + connections: "つながり", + noConnections: "つながりはありません", + history: "履歴", + noHistory: "リレーション履歴はありません", + undo: "元に戻す", + apply: "適用", + addToSelected: "追加", + removeFromSelected: "削除", + removeProperty: "リレーションプロパティを削除", + relationshipsPreserved: "リレーションは既定で保持されます", + selectRelationshipsToRemove: "選択したリレーションも削除", + removePropertyOnly: "プロパティを削除", + removePropertyAndSelected: "プロパティと選択した {{count}} 件を削除", + retryDatabases: "データベースを再読み込み", + retryRelationships: "リレーションを再読み込み", + noExistingRelationships: "既存のリレーションはありません", + configurationFailed: "リレーションを追加できませんでした。", + loadFailed: "リレーションを読み込めませんでした。", + tryAgain: "再試行", + routeUnavailable: "このリレーションはここからは読み取り専用です。", + refreshBeforeReplacing: + "このリレーションを置き換える前に更新してください。", + updateFailed: "リレーションを更新できませんでした。", + requestInterrupted: + "リクエストが中断されました。同じ変更を再試行して、保存されたか確認してください。", + retrySavedChange: "同じ変更を再試行", + legacyUnsupported: "この従来のリレーションはまだここでは編集できません。", + retryConnections: "つながりを再読み込み", + connectionRemoved: "つながりを削除しました", + removeFailed: "つながりを削除できませんでした。", + retryHistory: "履歴を再読み込み", + historyDetailsUnavailable: + "リレーション履歴の詳細を利用できません。履歴を再読み込みしてください。", + undoChange: "変更を元に戻す", + changeUndone: "変更を元に戻しました", + undoFailed: "この変更を元に戻せませんでした。", + noSharedRelationships: "選択した行に一致するリレーションはありません", + bulkFailed: "リレーションは変更されませんでした。", + bulkInverseMaxOne: + "1 ページのリレーションでは、1 つの参照元ページを複数の選択行に割り当てられません。", + filterRelationships: "リレーションを絞り込む", + noMatchingRelationships: "一致するリレーションはありません", + removalPreviewFailed: "リレーションのプレビューを読み込めませんでした。", + propertyRemoved: "リレーションプロパティを削除しました", + removePropertyFailed: "リレーションプロパティを削除できませんでした。", + states: { active: "有効", suspended: "一時停止", inactive: "無効" }, + }, + "ko-KR": { + ...relationshipMessages, + addRelation: "관계 추가", + adding: "추가 중…", + definition: "관계 정의", + newRelationship: "새 관계", + existingRelationship: "기존 관계", + forwardLabel: "속성 이름", + inverseLabel: "역방향 이름", + cardinality: "행당 페이지 수", + onePage: "페이지 하나", + manyPages: "여러 페이지", + targetDatabase: "대상 데이터베이스", + searchDatabases: "데이터베이스 검색", + noDatabases: "일치하는 데이터베이스가 없습니다", + createInverseProperty: "역방향 속성 만들기", + inverseEditable: "역방향 속성에서 편집 허용", + searchPages: "페이지 검색", + noMatchingPages: "일치하는 페이지가 없습니다", + done: "완료", + cancel: "취소", + connections: "연결", + noConnections: "연결 없음", + history: "기록", + noHistory: "관계 기록 없음", + undo: "실행 취소", + apply: "적용", + addToSelected: "추가", + removeFromSelected: "제거", + removeProperty: "관계 속성 제거", + relationshipsPreserved: "관계는 기본적으로 유지됩니다", + selectRelationshipsToRemove: "선택한 관계도 제거", + removePropertyOnly: "속성 제거", + removePropertyAndSelected: "속성 및 선택한 {{count}}개 제거", + retryDatabases: "데이터베이스 다시 불러오기", + retryRelationships: "관계 다시 불러오기", + noExistingRelationships: "기존 관계 없음", + configurationFailed: "관계를 추가하지 못했습니다.", + loadFailed: "관계를 불러오지 못했습니다.", + tryAgain: "다시 시도", + routeUnavailable: "여기서는 이 관계를 읽기만 할 수 있습니다.", + refreshBeforeReplacing: "이 관계를 바꾸기 전에 새로 고침하세요.", + updateFailed: "관계를 업데이트하지 못했습니다.", + requestInterrupted: + "요청이 중단되었습니다. 동일한 변경을 다시 시도하여 저장되었는지 확인하세요.", + retrySavedChange: "동일한 변경 다시 시도", + legacyUnsupported: "이 기존 관계는 아직 여기서 편집할 수 없습니다.", + retryConnections: "연결 다시 불러오기", + connectionRemoved: "연결 제거됨", + removeFailed: "연결을 제거하지 못했습니다.", + retryHistory: "기록 다시 불러오기", + historyDetailsUnavailable: + "관계 기록 세부 정보를 사용할 수 없습니다. 기록을 다시 불러오세요.", + undoChange: "변경 실행 취소", + changeUndone: "변경을 실행 취소했습니다", + undoFailed: "이 변경을 실행 취소하지 못했습니다.", + noSharedRelationships: "선택한 행에 일치하는 관계가 없습니다", + bulkFailed: "변경된 관계가 없습니다.", + bulkInverseMaxOne: + "한 페이지 관계에서는 하나의 원본 페이지를 선택한 여러 행에 할당할 수 없습니다.", + filterRelationships: "관계 필터링", + noMatchingRelationships: "일치하는 관계 없음", + removalPreviewFailed: "관계 미리보기를 불러오지 못했습니다.", + propertyRemoved: "관계 속성이 제거되었습니다", + removePropertyFailed: "관계 속성을 제거하지 못했습니다.", + states: { active: "활성", suspended: "일시 중지", inactive: "비활성" }, + }, + "pt-BR": { + ...relationshipMessages, + addRelation: "Adicionar relação", + adding: "Adicionando…", + definition: "Definição da relação", + newRelationship: "Nova relação", + existingRelationship: "Relação existente", + forwardLabel: "Nome da propriedade", + inverseLabel: "Nome inverso", + cardinality: "Páginas por linha", + onePage: "Uma página", + manyPages: "Várias páginas", + targetDatabase: "Banco de dados de destino", + searchDatabases: "Pesquisar bancos de dados", + noDatabases: "Nenhum banco de dados correspondente", + createInverseProperty: "Criar propriedade inversa", + inverseEditable: "Permitir edição pela propriedade inversa", + searchPages: "Pesquisar páginas", + noMatchingPages: "Nenhuma página correspondente", + done: "Concluído", + cancel: "Cancelar", + connections: "Conexões", + noConnections: "Sem conexões", + history: "Histórico", + noHistory: "Sem histórico de relações", + undo: "Desfazer", + apply: "Aplicar", + addToSelected: "Adicionar", + removeFromSelected: "Remover", + removeProperty: "Remover propriedade de relação", + relationshipsPreserved: "As relações são preservadas por padrão", + selectRelationshipsToRemove: "Remover também as relações selecionadas", + removePropertyOnly: "Remover propriedade", + removePropertyAndSelected: "Remover propriedade e {{count}} selecionadas", + retryDatabases: "Tentar carregar bancos de dados novamente", + retryRelationships: "Tentar carregar relações novamente", + noExistingRelationships: "Nenhuma relação existente", + configurationFailed: "Não foi possível adicionar a relação.", + loadFailed: "Não foi possível carregar as relações.", + tryAgain: "Tentar novamente", + routeUnavailable: "Esta relação é somente leitura neste local.", + refreshBeforeReplacing: "Atualize antes de substituir esta relação.", + updateFailed: "Não foi possível atualizar a relação.", + requestInterrupted: + "O pedido foi interrompido. Repita a mesma alteração para confirmar se foi guardada.", + retrySavedChange: "Repetir a mesma alteração", + legacyUnsupported: "Esta relação antiga ainda não pode ser editada aqui.", + retryConnections: "Tentar carregar conexões novamente", + connectionRemoved: "Conexão removida", + removeFailed: "Não foi possível remover a conexão.", + retryHistory: "Tentar carregar histórico novamente", + historyDetailsUnavailable: + "Os detalhes do histórico de relações estão indisponíveis. Recarregue o histórico.", + undoChange: "Desfazer alteração", + changeUndone: "Alteração desfeita", + undoFailed: "Não foi possível desfazer esta alteração.", + noSharedRelationships: + "Nenhuma relação correspondente nas linhas selecionadas", + bulkFailed: "Nenhuma relação foi alterada.", + bulkInverseMaxOne: + "Uma relação de uma página não pode atribuir uma página de origem a várias linhas selecionadas.", + filterRelationships: "Filtrar relações", + noMatchingRelationships: "Nenhuma relação correspondente", + removalPreviewFailed: + "Não foi possível carregar a visualização das relações.", + propertyRemoved: "Propriedade de relação removida", + removePropertyFailed: "Não foi possível remover a propriedade de relação.", + states: { active: "Ativa", suspended: "Suspensa", inactive: "Inativa" }, + }, + "hi-IN": { + ...relationshipMessages, + addRelation: "रिलेशन जोड़ें", + adding: "जोड़ा जा रहा है…", + definition: "रिलेशन की परिभाषा", + newRelationship: "नया रिलेशन", + existingRelationship: "मौजूदा रिलेशन", + forwardLabel: "प्रॉपर्टी का नाम", + inverseLabel: "उलटा नाम", + cardinality: "हर पंक्ति में पेज", + onePage: "एक पेज", + manyPages: "कई पेज", + targetDatabase: "लक्षित डेटाबेस", + searchDatabases: "डेटाबेस खोजें", + noDatabases: "कोई मिलता हुआ डेटाबेस नहीं", + createInverseProperty: "उलटी प्रॉपर्टी बनाएँ", + inverseEditable: "उलटी प्रॉपर्टी से संपादन की अनुमति दें", + searchPages: "पेज खोजें", + noMatchingPages: "कोई मिलता हुआ पेज नहीं", + done: "पूरा हुआ", + cancel: "रद्द करें", + connections: "कनेक्शन", + noConnections: "कोई कनेक्शन नहीं", + history: "इतिहास", + noHistory: "रिलेशन का कोई इतिहास नहीं", + undo: "पहले जैसा करें", + apply: "लागू करें", + addToSelected: "जोड़ें", + removeFromSelected: "हटाएँ", + removeProperty: "रिलेशन प्रॉपर्टी हटाएँ", + relationshipsPreserved: "रिलेशन डिफ़ॉल्ट रूप से सुरक्षित रहते हैं", + selectRelationshipsToRemove: "चुने हुए रिलेशन भी हटाएँ", + removePropertyOnly: "प्रॉपर्टी हटाएँ", + removePropertyAndSelected: "प्रॉपर्टी और चुने हुए {{count}} हटाएँ", + retryDatabases: "डेटाबेस फिर लोड करें", + retryRelationships: "रिलेशन फिर लोड करें", + noExistingRelationships: "कोई मौजूदा रिलेशन नहीं", + configurationFailed: "रिलेशन नहीं जोड़ा जा सका।", + loadFailed: "रिलेशन लोड नहीं किए जा सके।", + tryAgain: "फिर कोशिश करें", + routeUnavailable: "इस रिलेशन को यहाँ से केवल देखा जा सकता है।", + refreshBeforeReplacing: "इस रिलेशन को बदलने से पहले रीफ़्रेश करें।", + updateFailed: "रिलेशन अपडेट नहीं किया जा सका।", + requestInterrupted: + "अनुरोध बाधित हो गया। यह पुष्टि करने के लिए वही बदलाव फिर से आज़माएँ कि वह सहेजा गया था या नहीं।", + retrySavedChange: "वही बदलाव फिर से आज़माएँ", + legacyUnsupported: "इस पुराने रिलेशन को अभी यहाँ संपादित नहीं किया जा सकता।", + retryConnections: "कनेक्शन फिर लोड करें", + connectionRemoved: "कनेक्शन हटा दिया गया", + removeFailed: "कनेक्शन नहीं हटाया जा सका।", + retryHistory: "इतिहास फिर लोड करें", + historyDetailsUnavailable: + "रिलेशनशिप इतिहास का विवरण उपलब्ध नहीं है। इतिहास फिर से लोड करें।", + undoChange: "बदलाव पहले जैसा करें", + changeUndone: "बदलाव पहले जैसा कर दिया गया", + undoFailed: "यह बदलाव पहले जैसा नहीं किया जा सका।", + noSharedRelationships: "चुनी हुई पंक्तियों में कोई मिलता हुआ रिलेशन नहीं", + bulkFailed: "कोई रिलेशन नहीं बदला।", + bulkInverseMaxOne: + "एक-पेज वाला रिलेशन एक स्रोत पेज को कई चुनी हुई पंक्तियों से नहीं जोड़ सकता।", + filterRelationships: "रिलेशन फ़िल्टर करें", + noMatchingRelationships: "कोई मिलता हुआ रिलेशन नहीं", + removalPreviewFailed: "रिलेशन का प्रीव्यू लोड नहीं किया जा सका।", + propertyRemoved: "रिलेशन प्रॉपर्टी हटा दी गई", + removePropertyFailed: "रिलेशन प्रॉपर्टी नहीं हटाई जा सकी।", + states: { active: "सक्रिय", suspended: "रुका हुआ", inactive: "निष्क्रिय" }, + }, + "ar-SA": { + ...relationshipMessages, + addRelation: "إضافة علاقة", + adding: "جارٍ الإضافة…", + definition: "تعريف العلاقة", + newRelationship: "علاقة جديدة", + existingRelationship: "علاقة موجودة", + forwardLabel: "اسم الخاصية", + inverseLabel: "الاسم العكسي", + cardinality: "الصفحات لكل صف", + onePage: "صفحة واحدة", + manyPages: "صفحات متعددة", + targetDatabase: "قاعدة البيانات المستهدفة", + searchDatabases: "البحث في قواعد البيانات", + noDatabases: "لا توجد قواعد بيانات مطابقة", + createInverseProperty: "إنشاء خاصية عكسية", + inverseEditable: "السماح بالتحرير من الخاصية العكسية", + searchPages: "البحث في الصفحات", + noMatchingPages: "لا توجد صفحات مطابقة", + done: "تم", + cancel: "إلغاء", + connections: "الاتصالات", + noConnections: "لا توجد اتصالات", + history: "السجل", + noHistory: "لا يوجد سجل للعلاقات", + undo: "تراجع", + apply: "تطبيق", + addToSelected: "إضافة", + removeFromSelected: "إزالة", + removeProperty: "إزالة خاصية العلاقة", + relationshipsPreserved: "يتم الاحتفاظ بالعلاقات افتراضيًا", + selectRelationshipsToRemove: "إزالة العلاقات المحددة أيضًا", + removePropertyOnly: "إزالة الخاصية", + removePropertyAndSelected: "إزالة الخاصية و{{count}} من المحدد", + retryDatabases: "إعادة تحميل قواعد البيانات", + retryRelationships: "إعادة تحميل العلاقات", + noExistingRelationships: "لا توجد علاقات حالية", + configurationFailed: "تعذرت إضافة العلاقة.", + loadFailed: "تعذر تحميل العلاقات.", + tryAgain: "المحاولة مرة أخرى", + routeUnavailable: "هذه العلاقة للقراءة فقط من هنا.", + refreshBeforeReplacing: "حدّث قبل استبدال هذه العلاقة.", + updateFailed: "تعذر تحديث العلاقة.", + requestInterrupted: + "انقطع الطلب. أعد محاولة التغيير نفسه للتأكد مما إذا كان قد تم حفظه.", + retrySavedChange: "إعادة محاولة التغيير نفسه", + legacyUnsupported: "لا يمكن تحرير هذه العلاقة القديمة هنا بعد.", + retryConnections: "إعادة تحميل الاتصالات", + connectionRemoved: "تمت إزالة الاتصال", + removeFailed: "تعذرت إزالة الاتصال.", + retryHistory: "إعادة تحميل السجل", + historyDetailsUnavailable: "تفاصيل سجل العلاقات غير متاحة. أعد تحميل السجل.", + undoChange: "التراجع عن التغيير", + changeUndone: "تم التراجع عن التغيير", + undoFailed: "تعذر التراجع عن هذا التغيير.", + noSharedRelationships: "لا توجد علاقات مطابقة في الصفوف المحددة", + bulkFailed: "لم تتغير أي علاقات.", + bulkInverseMaxOne: + "لا يمكن لعلاقة من صفحة واحدة تعيين صفحة مصدر واحدة إلى عدة صفوف محددة.", + filterRelationships: "تصفية العلاقات", + noMatchingRelationships: "لا توجد علاقات مطابقة", + removalPreviewFailed: "تعذر تحميل معاينة العلاقات.", + propertyRemoved: "تمت إزالة خاصية العلاقة", + removePropertyFailed: "تعذرت إزالة خاصية العلاقة.", + states: { active: "نشطة", suspended: "معلقة", inactive: "غير نشطة" }, + }, +} satisfies Record, typeof relationshipMessages>; + const rawLiteralLocaleMessages: Partial> = { "zh-CN": { database: { @@ -9905,6 +10664,7 @@ function mergeMessages(overrides: PartialMessages): Messages { theme: { ...enUS.theme, ...overrides.theme }, navigation: { ...enUS.navigation, ...overrides.navigation }, landing: { ...enUS.landing, ...overrides.landing }, + relationships: { ...enUS.relationships, ...overrides.relationships }, team: { ...enUS.team, ...overrides.team }, settings: { ...enUS.settings, ...overrides.settings }, chat: { ...enUS.chat, ...overrides.chat }, @@ -10003,6 +10763,10 @@ function mergeMessagesForLocale( ...base, comments: { ...base.comments, ...commentMessagesByLocale[locale] }, landing: { ...base.landing, ...landingMessagesByLocale[locale] }, + relationships: { + ...base.relationships, + ...relationshipMessagesByLocale[locale], + }, root: { ...base.root, ...rawLiteralOverrides.root }, team: { ...base.team, ...rawLiteralOverrides.team }, settings: { ...base.settings, ...rawLiteralOverrides.settings }, diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index 8274ca22197..c4efafce916 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -33,6 +33,96 @@ const messages = { agent: "代理", settings: "設定", }, + relationships: { + addRelation: "新增關聯", + adding: "正在新增…", + definition: "關聯定義", + newRelationship: "新關聯", + existingRelationship: "現有關聯", + forwardLabel: "屬性名稱", + forwardLabelPlaceholder: "貢獻者", + inverseLabel: "反向名稱", + inverseLabelPlaceholder: "交付項目", + cardinality: "每列頁面數", + onePage: "一個頁面", + manyPages: "多個頁面", + targetDatabase: "目標資料庫", + searchDatabases: "搜尋資料庫", + retryDatabases: "重新載入資料庫", + noDatabases: "沒有相符的資料庫", + createInverseProperty: "建立反向屬性", + inverseEditable: "允許從反向屬性編輯", + retryRelationships: "重新載入關聯", + noExistingRelationships: "沒有現有關聯", + configurationFailed: "無法新增關聯。", + valueUnavailable: "無法使用", + empty: "空白", + moreCount: "+{{count}} 個", + searchPages: "搜尋頁面", + searchForProperty: "搜尋 {{name}} 的頁面", + noMatchingPages: "沒有相符的頁面", + loadFailed: "無法載入關聯。", + tryAgain: "再試一次", + routeUnavailable: "此關聯在這裡是唯讀的。", + refreshBeforeReplacing: "請重新整理後再取代此關聯。", + updateFailed: "無法更新關聯。", + requestInterrupted: "請求已中斷。請重試相同變更,以確認是否已儲存。", + retrySavedChange: "重試相同變更", + legacyUnsupported: "尚無法在這裡編輯此舊版關聯。", + done: "完成", + cancel: "取消", + connections: "連結", + noConnections: "沒有連結", + retryConnections: "重新載入連結", + removeConnection: "移除與 {{name}} 的連結", + connectionRemoved: "已移除連結", + removeFailed: "無法移除連結。", + history: "歷史記錄", + noHistory: "沒有關聯歷史記錄", + retryHistory: "重新載入歷史記錄", + historyDetailsUnavailable: + "無法使用關聯歷史記錄詳細資料。請重新載入歷史記錄。", + historyChangeAdded: "已新增", + historyChangeRemoved: "已移除", + historyChangeReplaced: "已取代", + historyChangeRestored: "已還原", + historyPreviousTarget: "先前為 {{name}}", + historyActor: "{{kind}}:{{name}}", + historyActorPerson: "使用者", + historyActorAgent: "代理", + historyActorAutomation: "自動化", + historyActorProgrammatic: "程式呼叫", + historyOrigin: "來源:{{origin}}", + historyAuthorizedBy: "授權者:{{principal}}", + undo: "復原", + undoChange: "復原變更", + changeUndone: "已復原變更", + undoFailed: "無法復原此變更。", + addToSelected: "新增", + removeFromSelected: "移除", + noSharedRelationships: "所選資料列中沒有相符的關聯", + apply: "套用", + bulkAdded: "已將關聯新增至 {{count}} 列", + bulkRemoved: "已從 {{count}} 列移除關聯", + bulkFailed: "未變更任何關聯。", + bulkInverseMaxOne: "單頁關聯無法將一個來源頁面指派給多個所選資料列。", + removeProperty: "移除關聯屬性", + removePropertyNamed: "從此資料庫移除 {{name}}。", + relationshipsPreserved: "預設會保留關聯", + selectRelationshipsToRemove: "同時移除所選關聯", + filterRelationships: "篩選關聯", + noMatchingRelationships: "沒有相符的關聯", + removalPreviewFailed: "無法載入關聯預覽。", + removePropertyOnly: "僅移除屬性", + removePropertyAndSelected: "移除屬性和 {{count}} 個所選關聯", + propertyRemoved: "已移除關聯屬性", + removePropertyFailed: "無法移除關聯屬性。", + states: { + active: "有效", + suspended: "已暫停", + inactive: "無效", + }, + }, landing: { previousPageUnavailable: "您先前的頁面已無法使用,因此我們開啟了歡迎頁面。", saveFailed: "無法儲存您的位置", diff --git a/templates/content/changelog/2026-09-08-typed-page-relationships.md b/templates/content/changelog/2026-09-08-typed-page-relationships.md new file mode 100644 index 00000000000..2d52b8e3829 --- /dev/null +++ b/templates/content/changelog/2026-09-08-typed-page-relationships.md @@ -0,0 +1,5 @@ +--- +type: added +date: 2026-09-08 +--- +Connect pages with Relation Properties, manage assignments from either direction, and recover relationship changes from history. diff --git a/templates/content/docs/product/capabilities/content.relationship.edge.md b/templates/content/docs/product/capabilities/content.relationship.edge.md index 0b539fe796c..f68ae0cfe8b 100644 --- a/templates/content/docs/product/capabilities/content.relationship.edge.md +++ b/templates/content/docs/product/capabilities/content.relationship.edge.md @@ -6,7 +6,7 @@ name: "Typed Relationships" user_promise: "One typed edge substrate powers relation Properties, inline typed Page references, backlinks, Info, graph queries, and Graph editing" primary_user_job: "Connect two Pages once, give that connection a useful meaning, and manage it consistently from any Content surface." kind: "primitive" -state: "approved_shape" +state: "in_progress" publicness: "public" availability: "universal" dependencies: @@ -35,7 +35,7 @@ proof_requirements: ] evidence: [] superseded_by: null -last_reviewed: "2026-07-29" +last_reviewed: "2026-09-09" --- # Typed Relationships @@ -164,7 +164,11 @@ Given an authorized Page related to an endpoint the viewer cannot access, when t ## Current evidence -Current code can model and display some relation values, which is useful donor substrate. Relation is not yet a generally user-creatable Property, and the current Notion path treats relations as unsupported. No evidence currently proves the shared type identity, universal Connections editor, bulk/deletion semantics, cardinality, access closure, source policy, or causal concurrency contract. This Capability therefore remains `approved_shape`. +The first implementation slice adds local directional types within one Content space, forward one/many and inverse many cardinality, Database admission constraints, canonical Relation Property projections, Connections, bounded atomic Actions, and relationship-scoped history and recovery. It is work in progress, not verification of this entire Capability. + +Focused local integration tests cover canonical projection hydration, access-filtered export, legacy write rejection, and Page lifecycle behavior. `actions/relationship-concurrency.postgres.test.ts` exercises separate PostgreSQL connections for duplicate additions, observed removals, operation replay/conflict, and max-one replacement. Technical tests do not establish real-interface acceptance or deployed availability. + +The narrower slice still requires completed access/recovery review and real UI/internal-agent/external-MCP workflow evidence before acceptance. Governed definitions, symmetric/self-enabled types, Query-backed candidate selection, inline semantic references, Graph/Canvas, and source/import/Rule adapters remain outside this slice and retain the proof requirements below. Existing source relation payloads are not automatically migrated into canonical edges. ## Proof plan diff --git a/templates/content/docs/product/encyclopedia.md b/templates/content/docs/product/encyclopedia.md index 596fa2b4bde..eee0f62bd49 100644 --- a/templates/content/docs/product/encyclopedia.md +++ b/templates/content/docs/product/encyclopedia.md @@ -16,8 +16,8 @@ This index summarizes the atomic product contracts beneath the public roadmap. E | Verified | 4 | | Failing | 1 | | Stale | 0 | -| In Progress | 18 | -| Approved Shape | 89 | +| In Progress | 19 | +| Approved Shape | 88 | | Exploring | 8 | | Deferred | 0 | | Superseded | 5 | @@ -430,9 +430,9 @@ graph LR ## Relationship -| Capability | State | User promise | -| ---------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| [Typed Relationships](capabilities/content.relationship.edge.md) | Approved Shape | One typed edge substrate powers relation Properties, inline typed Page references, backlinks, Info, graph queries, and Graph editing | +| Capability | State | User promise | +| ---------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| [Typed Relationships](capabilities/content.relationship.edge.md) | In Progress | One typed edge substrate powers relation Properties, inline typed Page references, backlinks, Info, graph queries, and Graph editing | ## Renderer diff --git a/templates/content/server/db/schema.ts b/templates/content/server/db/schema.ts index 2dc9ade1196..2eb75662759 100644 --- a/templates/content/server/db/schema.ts +++ b/templates/content/server/db/schema.ts @@ -574,6 +574,368 @@ export const documentEditReceipts = table( ], ); +export const contentRelationshipTypes = table( + "content_relationship_types", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + spaceId: text("space_id").notNull(), + currentVersionId: text("current_version_id").notNull(), + state: text("state").notNull().default("active"), + provenance: text("provenance").notNull().default("local"), + createdBy: text("created_by").notNull(), + archivedAt: text("archived_at"), + createdAt: text("created_at").notNull().default(now()), + updatedAt: text("updated_at").notNull().default(now()), + }, + (relationshipType) => [ + index("content_relationship_types_space_state_idx").on( + relationshipType.spaceId, + relationshipType.state, + ), + index("content_relationship_types_owner_space_idx").on( + relationshipType.ownerEmail, + relationshipType.spaceId, + ), + ], +); + +export const contentRelationshipTypeVersions = table( + "content_relationship_type_versions", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + spaceId: text("space_id").notNull(), + relationshipTypeId: text("relationship_type_id").notNull(), + version: integer("version").notNull(), + forwardLabel: text("forward_label").notNull(), + inverseLabel: text("inverse_label").notNull(), + forwardCardinality: text("forward_cardinality").notNull(), + inverseCardinality: text("inverse_cardinality").notNull().default("many"), + sourceDatabaseId: text("source_database_id").notNull(), + targetDatabaseId: text("target_database_id").notNull(), + directionalKind: text("directional_kind").notNull().default("directional"), + allowSelf: integer("allow_self").notNull().default(0), + selectorKind: text("selector_kind").notNull().default("database"), + createdBy: text("created_by").notNull(), + createdAt: text("created_at").notNull().default(now()), + }, + (version) => [ + uniqueIndex("content_relationship_versions_type_version_unique").on( + version.relationshipTypeId, + version.version, + ), + index("content_relationship_versions_source_database_idx").on( + version.sourceDatabaseId, + ), + index("content_relationship_versions_target_database_idx").on( + version.targetDatabaseId, + ), + ], +); + +export const contentRelationshipProjections = table( + "content_relationship_projections", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + spaceId: text("space_id").notNull(), + propertyId: text("property_id").notNull(), + databaseId: text("database_id").notNull(), + relationshipTypeId: text("relationship_type_id").notNull(), + direction: text("direction").notNull(), + editable: integer("editable").notNull().default(0), + alias: text("alias").notNull(), + description: text("description").notNull().default(""), + createdBy: text("created_by").notNull(), + archivedAt: text("archived_at"), + createdAt: text("created_at").notNull().default(now()), + updatedAt: text("updated_at").notNull().default(now()), + }, + (projection) => [ + uniqueIndex("content_relationship_projections_property_unique").on( + projection.propertyId, + ), + index("content_relationship_projections_database_idx").on( + projection.databaseId, + ), + index("content_relationship_projections_type_direction_idx").on( + projection.relationshipTypeId, + projection.direction, + ), + ], +); + +export const contentRelationshipLineages = table( + "content_relationship_lineages", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + spaceId: text("space_id").notNull(), + relationshipTypeId: text("relationship_type_id").notNull(), + sourcePageId: text("source_page_id").notNull(), + targetPageId: text("target_page_id").notNull(), + provenance: text("provenance").notNull().default("local"), + createdBy: text("created_by").notNull(), + createdAt: text("created_at").notNull().default(now()), + updatedAt: text("updated_at").notNull().default(now()), + }, + (lineage) => [ + uniqueIndex("content_relationship_lineages_tuple_unique").on( + lineage.relationshipTypeId, + lineage.sourcePageId, + lineage.targetPageId, + ), + index("content_relationship_lineages_type_source_idx").on( + lineage.relationshipTypeId, + lineage.sourcePageId, + ), + index("content_relationship_lineages_type_target_idx").on( + lineage.relationshipTypeId, + lineage.targetPageId, + ), + ], +); + +export const contentRelationshipActivations = table( + "content_relationship_activations", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + spaceId: text("space_id").notNull(), + lineageId: text("lineage_id").notNull(), + addedEventId: text("added_event_id").notNull(), + createdBy: text("created_by").notNull(), + createdAt: text("created_at").notNull().default(now()), + }, + (activation) => [ + index("content_relationship_activations_lineage_idx").on( + activation.lineageId, + ), + uniqueIndex("content_relationship_activations_event_unique").on( + activation.addedEventId, + activation.lineageId, + ), + ], +); + +export const contentRelationshipActivationRetirements = table( + "content_relationship_activation_retirements", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + spaceId: text("space_id").notNull(), + activationId: text("activation_id").notNull(), + removedEventId: text("removed_event_id").notNull(), + removedBy: text("removed_by").notNull(), + removedAt: text("removed_at").notNull().default(now()), + }, + (retirement) => [ + uniqueIndex("content_relationship_retirements_activation_unique").on( + retirement.activationId, + ), + index("content_relationship_retirements_event_idx").on( + retirement.removedEventId, + ), + ], +); + +export const contentRelationshipCardinalitySlots = table( + "content_relationship_cardinality_slots", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + spaceId: text("space_id").notNull(), + relationshipTypeId: text("relationship_type_id").notNull(), + sourcePageId: text("source_page_id").notNull(), + lineageId: text("lineage_id"), + targetPageId: text("target_page_id"), + updatedAt: text("updated_at").notNull().default(now()), + }, + (slot) => [ + uniqueIndex("content_relationship_slots_type_source_unique").on( + slot.relationshipTypeId, + slot.sourcePageId, + ), + ], +); + +export const contentRelationshipRevisions = table( + "content_relationship_revisions", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + spaceId: text("space_id").notNull(), + operationId: text("operation_id").notNull(), + operation: text("operation").notNull(), + actorJson: text("actor_json").notNull().default("{}"), + authorizingPrincipalJson: text("authorizing_principal_json") + .notNull() + .default("{}"), + origin: text("origin").notNull(), + recoveryToken: text("recovery_token").notNull(), + diffJson: text("diff_json").notNull().default("{}"), + compensatesRevisionId: text("compensates_revision_id"), + createdAt: text("created_at").notNull().default(now()), + }, + (revision) => [ + index("content_relationship_revisions_space_created_idx").on( + revision.spaceId, + revision.createdAt, + ), + index("content_relationship_revisions_operation_idx").on( + revision.operationId, + ), + ], +); + +export const contentRelationshipEvents = table( + "content_relationship_events", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + spaceId: text("space_id").notNull(), + revisionId: text("revision_id").notNull(), + sequence: integer("sequence").notNull().default(0), + relationshipTypeId: text("relationship_type_id"), + relationshipTypeVersionId: text("relationship_type_version_id"), + kind: text("kind").notNull(), + actorJson: text("actor_json").notNull().default("{}"), + authorizingPrincipalJson: text("authorizing_principal_json") + .notNull() + .default("{}"), + origin: text("origin").notNull(), + runId: text("run_id"), + routeJson: text("route_json").notNull().default("{}"), + targetsJson: text("targets_json").notNull().default("{}"), + diffJson: text("diff_json").notNull().default("{}"), + outcome: text("outcome").notNull().default("committed"), + createdAt: text("created_at").notNull().default(now()), + }, + (event) => [ + index("content_relationship_events_revision_idx").on(event.revisionId), + index("content_relationship_events_type_created_idx").on( + event.relationshipTypeId, + event.createdAt, + ), + ], +); + +export const contentRelationshipReceipts = table( + "content_relationship_receipts", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + spaceId: text("space_id").notNull(), + callerScope: text("caller_scope").notNull(), + operationId: text("operation_id").notNull(), + requestHash: text("request_hash").notNull(), + revisionId: text("revision_id").notNull(), + resultJson: text("result_json").notNull().default("{}"), + createdAt: text("created_at").notNull().default(now()), + }, + (receipt) => [ + uniqueIndex("content_relationship_receipts_scoped_operation_unique").on( + receipt.spaceId, + receipt.callerScope, + receipt.operationId, + ), + index("content_relationship_receipts_revision_idx").on(receipt.revisionId), + ], +); + +export const contentRelationshipOperationLocks = table( + "content_relationship_operation_locks", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + spaceId: text("space_id").notNull(), + callerScope: text("caller_scope").notNull(), + operationId: text("operation_id").notNull(), + updatedAt: text("updated_at").notNull().default(now()), + }, + (lock) => [ + uniqueIndex("content_relationship_operation_locks_scope_unique").on( + lock.spaceId, + lock.callerScope, + lock.operationId, + ), + ], +); + +export const contentRelationshipObservations = table( + "content_relationship_observations", + { + token: text("token").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + spaceId: text("space_id").notNull(), + callerScope: text("caller_scope").notNull(), + kind: text("kind").notNull(), + edgeId: text("edge_id"), + relationshipTypeId: text("relationship_type_id").notNull(), + sourcePageId: text("source_page_id").notNull(), + activationIdsJson: text("activation_ids_json").notNull().default("[]"), + observedAt: text("observed_at").notNull().default(now()), + expiresAt: text("expires_at").notNull(), + }, + (observation) => [ + index("content_relationship_observations_scope_expiry_idx").on( + observation.callerScope, + observation.expiresAt, + ), + ], +); + +export const contentRelationshipRemovalSelections = table( + "content_relationship_removal_selections", + { + token: text("token").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + spaceId: text("space_id").notNull(), + callerScope: text("caller_scope").notNull(), + propertyId: text("property_id"), + selectionJson: text("selection_json").notNull().default("[]"), + recoveryToken: text("recovery_token").notNull(), + expiresAt: text("expires_at").notNull(), + usedAt: text("used_at"), + createdAt: text("created_at").notNull().default(now()), + }, + (selection) => [ + index("content_relationship_selections_scope_expiry_idx").on( + selection.callerScope, + selection.expiresAt, + ), + ], +); + +export const contentRelationshipEndpointStates = table( + "content_relationship_endpoint_states", + { + pageId: text("page_id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + spaceId: text("space_id").notNull(), + permanentlyDeletedAt: text("permanently_deleted_at"), + updatedAt: text("updated_at").notNull().default(now()), + }, + (state) => [ + index("content_relationship_endpoint_states_space_idx").on(state.spaceId), + ], +); + export const documentPropertyValues = table("document_property_values", { id: text("id").primaryKey(), ownerEmail: text("owner_email").notNull().default("local@localhost"), diff --git a/templates/content/server/plugins/db.ts b/templates/content/server/plugins/db.ts index cb7377ad6c4..9b31ecc8ef9 100644 --- a/templates/content/server/plugins/db.ts +++ b/templates/content/server/plugins/db.ts @@ -1087,6 +1087,245 @@ export const runContentMigrations = runMigrations( sql: `ALTER TABLE document_comments ADD COLUMN IF NOT EXISTS submission_source TEXT; ALTER TABLE document_comments ADD COLUMN IF NOT EXISTS submission_run_id TEXT`, }, + { + version: 89, + name: "share-tables-notified-at", + sql: ` + ALTER TABLE IF EXISTS document_shares ADD COLUMN IF NOT EXISTS notified_at TEXT + `, + }, + { + version: 90, + name: "content-canonical-typed-relationships", + sql: `CREATE TABLE IF NOT EXISTS content_relationship_types ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + space_id TEXT NOT NULL, + current_version_id TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'active', + provenance TEXT NOT NULL DEFAULT 'local', + created_by TEXT NOT NULL, + archived_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS content_relationship_types_space_state_idx ON content_relationship_types (space_id, state); + CREATE INDEX IF NOT EXISTS content_relationship_types_owner_space_idx ON content_relationship_types (owner_email, space_id); + + CREATE TABLE IF NOT EXISTS content_relationship_type_versions ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + space_id TEXT NOT NULL, + relationship_type_id TEXT NOT NULL, + version INTEGER NOT NULL, + forward_label TEXT NOT NULL, + inverse_label TEXT NOT NULL, + forward_cardinality TEXT NOT NULL, + inverse_cardinality TEXT NOT NULL DEFAULT 'many', + source_database_id TEXT NOT NULL, + target_database_id TEXT NOT NULL, + directional_kind TEXT NOT NULL DEFAULT 'directional', + allow_self INTEGER NOT NULL DEFAULT 0, + selector_kind TEXT NOT NULL DEFAULT 'database', + created_by TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE UNIQUE INDEX IF NOT EXISTS content_relationship_versions_type_version_unique ON content_relationship_type_versions (relationship_type_id, version); + CREATE INDEX IF NOT EXISTS content_relationship_versions_source_database_idx ON content_relationship_type_versions (source_database_id); + CREATE INDEX IF NOT EXISTS content_relationship_versions_target_database_idx ON content_relationship_type_versions (target_database_id); + + CREATE TABLE IF NOT EXISTS content_relationship_projections ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + space_id TEXT NOT NULL, + property_id TEXT NOT NULL, + database_id TEXT NOT NULL, + relationship_type_id TEXT NOT NULL, + direction TEXT NOT NULL, + editable INTEGER NOT NULL DEFAULT 0, + alias TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL, + archived_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE UNIQUE INDEX IF NOT EXISTS content_relationship_projections_property_unique ON content_relationship_projections (property_id); + CREATE INDEX IF NOT EXISTS content_relationship_projections_database_idx ON content_relationship_projections (database_id); + CREATE INDEX IF NOT EXISTS content_relationship_projections_type_direction_idx ON content_relationship_projections (relationship_type_id, direction); + + CREATE TABLE IF NOT EXISTS content_relationship_lineages ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + space_id TEXT NOT NULL, + relationship_type_id TEXT NOT NULL, + source_page_id TEXT NOT NULL, + target_page_id TEXT NOT NULL, + provenance TEXT NOT NULL DEFAULT 'local', + created_by TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE UNIQUE INDEX IF NOT EXISTS content_relationship_lineages_tuple_unique ON content_relationship_lineages (relationship_type_id, source_page_id, target_page_id); + CREATE INDEX IF NOT EXISTS content_relationship_lineages_type_source_idx ON content_relationship_lineages (relationship_type_id, source_page_id); + CREATE INDEX IF NOT EXISTS content_relationship_lineages_type_target_idx ON content_relationship_lineages (relationship_type_id, target_page_id); + + CREATE TABLE IF NOT EXISTS content_relationship_activations ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + space_id TEXT NOT NULL, + lineage_id TEXT NOT NULL, + added_event_id TEXT NOT NULL, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS content_relationship_activations_lineage_idx ON content_relationship_activations (lineage_id); + CREATE UNIQUE INDEX IF NOT EXISTS content_relationship_activations_event_unique ON content_relationship_activations (added_event_id, lineage_id); + + CREATE TABLE IF NOT EXISTS content_relationship_activation_retirements ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + space_id TEXT NOT NULL, + activation_id TEXT NOT NULL, + removed_event_id TEXT NOT NULL, + removed_by TEXT NOT NULL, + removed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE UNIQUE INDEX IF NOT EXISTS content_relationship_retirements_activation_unique ON content_relationship_activation_retirements (activation_id); + CREATE INDEX IF NOT EXISTS content_relationship_retirements_event_idx ON content_relationship_activation_retirements (removed_event_id); + + CREATE TABLE IF NOT EXISTS content_relationship_cardinality_slots ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + space_id TEXT NOT NULL, + relationship_type_id TEXT NOT NULL, + source_page_id TEXT NOT NULL, + lineage_id TEXT, + target_page_id TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE UNIQUE INDEX IF NOT EXISTS content_relationship_slots_type_source_unique ON content_relationship_cardinality_slots (relationship_type_id, source_page_id); + + CREATE TABLE IF NOT EXISTS content_relationship_revisions ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + space_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + operation TEXT NOT NULL, + actor_json TEXT NOT NULL DEFAULT '{}', + authorizing_principal_json TEXT NOT NULL DEFAULT '{}', + origin TEXT NOT NULL, + recovery_token TEXT NOT NULL, + diff_json TEXT NOT NULL DEFAULT '{}', + compensates_revision_id TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS content_relationship_revisions_space_created_idx ON content_relationship_revisions (space_id, created_at); + CREATE INDEX IF NOT EXISTS content_relationship_revisions_operation_idx ON content_relationship_revisions (operation_id); + + CREATE TABLE IF NOT EXISTS content_relationship_events ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + space_id TEXT NOT NULL, + revision_id TEXT NOT NULL, + sequence INTEGER NOT NULL DEFAULT 0, + relationship_type_id TEXT, + relationship_type_version_id TEXT, + kind TEXT NOT NULL, + actor_json TEXT NOT NULL DEFAULT '{}', + authorizing_principal_json TEXT NOT NULL DEFAULT '{}', + origin TEXT NOT NULL, + run_id TEXT, + route_json TEXT NOT NULL DEFAULT '{}', + targets_json TEXT NOT NULL DEFAULT '{}', + diff_json TEXT NOT NULL DEFAULT '{}', + outcome TEXT NOT NULL DEFAULT 'committed', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS content_relationship_events_revision_idx ON content_relationship_events (revision_id); + CREATE INDEX IF NOT EXISTS content_relationship_events_type_created_idx ON content_relationship_events (relationship_type_id, created_at); + + CREATE TABLE IF NOT EXISTS content_relationship_receipts ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + space_id TEXT NOT NULL, + caller_scope TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_hash TEXT NOT NULL, + revision_id TEXT NOT NULL, + result_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE UNIQUE INDEX IF NOT EXISTS content_relationship_receipts_scoped_operation_unique ON content_relationship_receipts (space_id, caller_scope, operation_id); + CREATE INDEX IF NOT EXISTS content_relationship_receipts_revision_idx ON content_relationship_receipts (revision_id); + + CREATE TABLE IF NOT EXISTS content_relationship_operation_locks ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + space_id TEXT NOT NULL, + caller_scope TEXT NOT NULL, + operation_id TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE UNIQUE INDEX IF NOT EXISTS content_relationship_operation_locks_scope_unique ON content_relationship_operation_locks (space_id, caller_scope, operation_id); + + CREATE TABLE IF NOT EXISTS content_relationship_observations ( + token TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + space_id TEXT NOT NULL, + caller_scope TEXT NOT NULL, + kind TEXT NOT NULL, + edge_id TEXT, + relationship_type_id TEXT NOT NULL, + source_page_id TEXT NOT NULL, + activation_ids_json TEXT NOT NULL DEFAULT '[]', + observed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS content_relationship_observations_scope_expiry_idx ON content_relationship_observations (caller_scope, expires_at); + + CREATE TABLE IF NOT EXISTS content_relationship_removal_selections ( + token TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + space_id TEXT NOT NULL, + caller_scope TEXT NOT NULL, + property_id TEXT, + selection_json TEXT NOT NULL DEFAULT '[]', + recovery_token TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS content_relationship_selections_scope_expiry_idx ON content_relationship_removal_selections (caller_scope, expires_at); + + CREATE TABLE IF NOT EXISTS content_relationship_endpoint_states ( + page_id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + space_id TEXT NOT NULL, + permanently_deleted_at TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS content_relationship_endpoint_states_space_idx ON content_relationship_endpoint_states (space_id)`, + }, + { + version: 91, + name: "content-relationship-event-order", + sql: `ALTER TABLE content_relationship_events ADD COLUMN IF NOT EXISTS sequence INTEGER NOT NULL DEFAULT 0`, + }, ], { table: "content_migrations" }, ); diff --git a/templates/content/shared/properties.test.ts b/templates/content/shared/properties.test.ts index 4eaa2ce0702..324c237d884 100644 --- a/templates/content/shared/properties.test.ts +++ b/templates/content/shared/properties.test.ts @@ -30,6 +30,19 @@ import { } from "./properties"; describe("document properties", () => { + it("preserves canonical relation identity and inverse policy through option serialization", () => { + const relation = { + databaseId: "team-database", + relationshipTypeId: "contributor-type", + direction: "inverse" as const, + editable: false, + }; + expect( + parsePropertyOptions(serializePropertyOptions({ relation })).relation, + ).toEqual(relation); + expect(CREATABLE_DOCUMENT_PROPERTY_TYPES).toContain("relation"); + }); + it("normalizes editable values by property type", () => { expect(normalizePropertyValue("text", "Draft")).toBe("Draft"); expect(normalizePropertyValue("person", "Alice Moore")).toEqual([ diff --git a/templates/content/shared/properties.ts b/templates/content/shared/properties.ts index d739346adcf..fed931a1138 100644 --- a/templates/content/shared/properties.ts +++ b/templates/content/shared/properties.ts @@ -49,6 +49,7 @@ export const CREATABLE_DOCUMENT_PROPERTY_TYPES = [ "url", "email", "phone", + "relation", "blocks", "id", "created_time", @@ -98,6 +99,9 @@ export interface DocumentPropertyOptions { formula?: string; relation?: { databaseId?: string | null; + relationshipTypeId?: string; + direction?: "forward" | "inverse"; + editable?: boolean; }; // Set on the default/primary "Content" Blocks field. The primary field is the // one whose content is backed by `documents.content` (the page body editor). @@ -319,6 +323,16 @@ export function parsePropertyOptions( relation: parsed.relation && typeof parsed.relation === "object" ? { + ...(typeof parsed.relation.editable === "boolean" + ? { editable: parsed.relation.editable } + : {}), + ...(typeof parsed.relation.relationshipTypeId === "string" + ? { relationshipTypeId: parsed.relation.relationshipTypeId } + : {}), + ...(parsed.relation.direction === "forward" || + parsed.relation.direction === "inverse" + ? { direction: parsed.relation.direction } + : {}), databaseId: typeof parsed.relation.databaseId === "string" ? parsed.relation.databaseId diff --git a/templates/content/shared/relationships.ts b/templates/content/shared/relationships.ts new file mode 100644 index 00000000000..b1443e7e839 --- /dev/null +++ b/templates/content/shared/relationships.ts @@ -0,0 +1,539 @@ +import { z } from "zod"; + +import { DOCUMENT_PROPERTY_VISIBILITIES } from "./properties.js"; + +export const RELATIONSHIP_ACTION_ERROR_CODES = [ + "NOT_ACCESSIBLE", + "ROUTE_NOT_AUTHORIZED", + "TYPE_UNAVAILABLE", + "UNSUPPORTED_CONFIGURATION", + "INVALID_TARGET", + "CONSTRAINT_UNAVAILABLE", + "CARDINALITY_VIOLATION", + "SOURCE_AUTHORITY_UNSUPPORTED", + "STALE_SELECTION", + "STALE_RECOVERY", + "IDEMPOTENCY_CONFLICT", + "LIMIT_EXCEEDED", + "UNAVAILABLE", + "USE_RELATIONSHIP_MUTATION", +] as const; + +export const relationshipActionErrorCodeSchema = z.enum( + RELATIONSHIP_ACTION_ERROR_CODES, +); +export type RelationshipActionErrorCode = z.infer< + typeof relationshipActionErrorCodeSchema +>; + +export const relationshipDirectionSchema = z.enum(["forward", "inverse"]); +export const relationshipListDirectionSchema = z.enum([ + "outgoing", + "incoming", + "both", +]); +export const relationshipCardinalitySchema = z.enum(["one", "many"]); +export const relationshipTypeStateSchema = z.enum(["active", "archived"]); +export const relationshipEdgeStateSchema = z.enum([ + "active", + "suspended", + "inactive", +]); + +export const canonicalRelationOptionsSchema = z + .object({ + databaseId: z.string().min(1), + relationshipTypeId: z.string().min(1), + direction: relationshipDirectionSchema, + editable: z.boolean(), + }) + .strict(); + +export const canonicalRelationProjectionSchema = z.object({ + id: z.string().min(1), + propertyId: z.string().min(1), + databaseId: z.string().min(1), + relationshipTypeId: z.string().min(1), + direction: relationshipDirectionSchema, + editable: z.boolean(), + alias: z.string(), + description: z.string(), + archivedAt: z.string().nullable(), +}); + +export const relationshipTypeVersionSchema = z.object({ + id: z.string().min(1), + relationshipTypeId: z.string().min(1), + version: z.number().int().positive(), + forwardLabel: z.string().min(1), + inverseLabel: z.string().min(1), + forwardCardinality: relationshipCardinalitySchema, + inverseCardinality: z.literal("many"), + sourceDatabaseId: z.string().min(1), + targetDatabaseId: z.string().min(1), + directional: z.literal(true), + allowSelf: z.literal(false), + selectorKind: z.literal("database"), +}); + +export const relationshipTypeSchema = z.object({ + id: z.string().min(1), + spaceId: z.string().min(1), + currentVersionId: z.string().min(1), + state: relationshipTypeStateSchema, + provenance: z.literal("local"), + archivedAt: z.string().nullable(), +}); + +export const relationshipRouteRefSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("forward-property"), + propertyId: z.string().min(1), + sourcePageId: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal("inverse-property"), + propertyId: z.string().min(1), + targetPageId: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal("connections-forward"), + sourcePageId: z.string().min(1), + }) + .strict(), +]); + +export const relationshipChangeSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("add"), + typeId: z.string().min(1), + typeVersionId: z.string().min(1), + sourcePageId: z.string().min(1), + targetPageId: z.string().min(1), + route: relationshipRouteRefSchema, + }) + .strict(), + z + .object({ + kind: z.literal("remove"), + edgeId: z.string().min(1), + observedActivationIds: z.array(z.string().min(1)).min(1).max(100), + observationToken: z.string().min(1), + route: relationshipRouteRefSchema, + }) + .strict(), + z + .object({ + kind: z.literal("replace"), + typeId: z.string().min(1), + typeVersionId: z.string().min(1), + sourcePageId: z.string().min(1), + targetPageId: z.string().min(1), + observedSlotToken: z.string().min(1), + route: relationshipRouteRefSchema, + }) + .strict(), +]); + +export const configureContentRelationPropertyInputSchema = z + .object({ + ownerDatabaseId: z.string().min(1), + propertyId: z.string().min(1).optional(), + alias: z.string().trim().min(1).max(200), + description: z.string().max(2_000).optional(), + editable: z.boolean().optional(), + visibility: z.enum(DOCUMENT_PROPERTY_VISIBILITIES).optional(), + definition: z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("new-local"), + forwardLabel: z.string().trim().min(1).max(200), + inverseLabel: z.string().trim().min(1).max(200), + forwardCardinality: relationshipCardinalitySchema, + sourceDatabaseId: z.string().min(1), + targetDatabaseId: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal("existing"), + relationshipTypeId: z.string().min(1), + direction: relationshipDirectionSchema, + }) + .strict(), + ]), + inverseProjection: z + .object({ + ownerDatabaseId: z.string().min(1), + propertyId: z.string().min(1).optional(), + alias: z.string().trim().min(1).max(200), + description: z.string().max(2_000).optional(), + editable: z.boolean(), + visibility: z.enum(DOCUMENT_PROPERTY_VISIBILITIES).optional(), + }) + .strict() + .optional(), + operationId: z.string().min(1).max(200), + }) + .strict(); + +export const listContentRelationshipTypesInputSchema = z + .object({ + databaseId: z.string().min(1), + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + }) + .strict(); + +export const listContentRelationCandidatesInputSchema = z + .object({ + propertyId: z.string().min(1), + anchorPageId: z.string().min(1), + search: z.string().trim().max(500).default(""), + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + contextPropertyIds: z.array(z.string().min(1)).max(20).default([]), + }) + .strict(); + +export const listContentRelationshipsInputSchema = z + .object({ + pageId: z.string().min(1).optional(), + databaseId: z.string().min(1).optional(), + relationshipTypeId: z.string().min(1).optional(), + direction: relationshipListDirectionSchema.default("both"), + oppositePageId: z.string().min(1).optional(), + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + }) + .strict() + .refine((value) => Boolean(value.pageId) !== Boolean(value.databaseId), { + message: "Provide exactly one of pageId or databaseId.", + path: ["pageId"], + }); + +export const mutateContentRelationshipsInputSchema = z + .object({ + operationId: z.string().min(1).max(200), + changes: z.array(relationshipChangeSchema).min(1).max(100), + }) + .strict(); + +export const prepareContentRelationshipRemovalInputSchema = z + .object({ + selection: z.discriminatedUnion("kind", [ + z + .object({ kind: z.literal("property"), propertyId: z.string().min(1) }) + .strict(), + z + .object({ + kind: z.literal("edges"), + edgeIds: z.array(z.string().min(1)).min(1).max(100), + }) + .strict(), + ]), + filter: z + .object({ + typeId: z.string().min(1).optional(), + direction: relationshipListDirectionSchema.optional(), + oppositePageId: z.string().min(1).optional(), + }) + .strict() + .optional(), + }) + .strict(); + +export const removeContentRelationPropertyInputSchema = z + .object({ + propertyId: z.string().min(1), + relationshipMode: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("keep") }).strict(), + z + .object({ + kind: z.literal("remove-selected"), + selectionReceipt: z.string().min(1), + edgeIds: z.array(z.string().min(1)).min(1).max(100).optional(), + }) + .strict(), + ]), + operationId: z.string().min(1).max(200), + }) + .strict(); + +export const listContentRelationshipHistoryInputSchema = z + .object({ + pageId: z.string().min(1).optional(), + relationshipTypeId: z.string().min(1).optional(), + revisionId: z.string().min(1).optional(), + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + }) + .strict() + .refine( + (value) => + Boolean(value.pageId || value.relationshipTypeId || value.revisionId), + { message: "Provide a Page, relationship type, or Revision ID." }, + ); + +export const undoContentRelationshipRevisionInputSchema = z + .object({ + revisionId: z.string().min(1), + recoveryToken: z.string().min(1), + operationId: z.string().min(1).max(200), + routes: z.array(relationshipRouteRefSchema).max(100).default([]), + }) + .strict(); + +export const relationshipCapabilitiesSchema = z.object({ + canConfigure: z.boolean(), + canAdd: z.boolean(), + canRemove: z.boolean(), + canReplace: z.boolean(), + canEditInverse: z.boolean(), +}); + +export const relationshipEndpointSchema = z.object({ + pageId: z.string().min(1), + title: z.string(), + state: z.enum(["active", "trashed"]), +}); + +export const contentRelationshipItemSchema = z.object({ + edgeId: z.string().min(1), + lineageId: z.string().min(1), + typeId: z.string().min(1), + typeVersionId: z.string().min(1), + sourcePageId: z.string().min(1), + targetPageId: z.string().min(1), + direction: relationshipListDirectionSchema.exclude(["both"]), + state: relationshipEdgeStateSchema, + observedActivationIds: z.array(z.string().min(1)), + observationToken: z.string().min(1), + slotObservationToken: z.string().min(1).nullable(), + source: relationshipEndpointSchema, + target: relationshipEndpointSchema, + relationship: z.object({ + forwardLabel: z.string(), + inverseLabel: z.string(), + label: z.string(), + forwardCardinality: relationshipCardinalitySchema, + }), + routes: z.array(relationshipRouteRefSchema), +}); + +export const relationshipMutationResultItemSchema = z.object({ + kind: z.enum(["add", "remove", "replace"]), + edgeId: z.string().min(1), + lineageId: z.string().min(1), + state: relationshipEdgeStateSchema, + activationIds: z.array(z.string().min(1)), + displacedEdgeIds: z.array(z.string().min(1)).optional(), +}); + +export const relationshipInvalidationSchema = z.object({ + pageIds: z.array(z.string().min(1)), + databaseIds: z.array(z.string().min(1)), + propertyIds: z.array(z.string().min(1)), + relationshipTypeIds: z.array(z.string().min(1)), +}); + +export const relationshipCommitReceiptSchema = z.object({ + operationId: z.string().min(1), + receiptId: z.string().min(1), + revisionId: z.string().min(1), + eventIds: z.array(z.string().min(1)), + invalidation: relationshipInvalidationSchema, +}); + +export const relationshipReadStateSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("ready"), + scope: z.literal("viewer-accessible"), + pageIds: z.array(z.string().min(1)), + }), + z.object({ + status: z.literal("unsupported"), + errorCode: z.literal("UNSUPPORTED_CONFIGURATION"), + }), + z.object({ + status: z.literal("unavailable"), + errorCode: z.literal("UNAVAILABLE"), + }), +]); + +export type CanonicalRelationOptions = z.infer< + typeof canonicalRelationOptionsSchema +>; +export type CanonicalRelationProjection = z.infer< + typeof canonicalRelationProjectionSchema +>; +export type RelationshipTypeVersion = z.infer< + typeof relationshipTypeVersionSchema +>; +export type RelationshipType = z.infer; +export type RelationshipRouteRef = z.infer; +export type RelationshipChange = z.infer; +export type ConfigureContentRelationPropertyInput = z.infer< + typeof configureContentRelationPropertyInputSchema +>; +export type ListContentRelationshipTypesInput = z.infer< + typeof listContentRelationshipTypesInputSchema +>; +export type ListContentRelationCandidatesInput = z.infer< + typeof listContentRelationCandidatesInputSchema +>; +export type ListContentRelationshipsInput = z.infer< + typeof listContentRelationshipsInputSchema +>; +export type MutateContentRelationshipsInput = z.infer< + typeof mutateContentRelationshipsInputSchema +>; +export type PrepareContentRelationshipRemovalInput = z.infer< + typeof prepareContentRelationshipRemovalInputSchema +>; +export type RemoveContentRelationPropertyInput = z.infer< + typeof removeContentRelationPropertyInputSchema +>; +export type ListContentRelationshipHistoryInput = z.infer< + typeof listContentRelationshipHistoryInputSchema +>; +export type UndoContentRelationshipRevisionInput = z.infer< + typeof undoContentRelationshipRevisionInputSchema +>; +export type RelationshipCapabilities = z.infer< + typeof relationshipCapabilitiesSchema +>; +export type ContentRelationshipItem = z.infer< + typeof contentRelationshipItemSchema +>; +export type RelationshipMutationResultItem = z.infer< + typeof relationshipMutationResultItemSchema +>; +export type RelationshipInvalidation = z.infer< + typeof relationshipInvalidationSchema +>; +export type RelationshipCommitReceipt = z.infer< + typeof relationshipCommitReceiptSchema +>; +export type RelationshipReadState = z.infer; + +export interface ConfigureContentRelationPropertyResult extends RelationshipCommitReceipt { + relationshipType: RelationshipType; + relationshipTypeVersion: RelationshipTypeVersion; + projection: CanonicalRelationProjection; + inverseProjection?: CanonicalRelationProjection; + capabilities: RelationshipCapabilities; + schemaRevision: string; +} + +export interface ListContentRelationshipTypesResult { + scope: "viewer-accessible"; + items: Array<{ + type: RelationshipType; + version: RelationshipTypeVersion; + projections: CanonicalRelationProjection[]; + capabilities: RelationshipCapabilities; + }>; + nextCursor: string | null; +} + +export interface ContentRelationCandidate { + pageId: string; + title: string; + context: Record; + /** + * Opaque observation for this candidate's forward max-one slot. Present on + * inverse pickers; it reveals no current target and is rechecked on commit. + */ + slotObservationToken: string | null; +} + +export interface ListContentRelationCandidatesResult { + scope: "viewer-accessible"; + items: ContentRelationCandidate[]; + /** Opaque observation for the fixed source slot in a forward max-one picker. */ + slotObservationToken: string | null; + nextCursor: string | null; +} + +export interface ListContentRelationshipsResult { + scope: "viewer-accessible"; + items: ContentRelationshipItem[]; + nextCursor: string | null; +} + +export interface MutateContentRelationshipsResult extends RelationshipCommitReceipt { + results: RelationshipMutationResultItem[]; +} + +export interface PrepareContentRelationshipRemovalResult { + selectionReceipt: string; + selectedCount: number; + edges: Array<{ edgeId: string; observedActivationIds: string[] }>; + expiresAt: string; + recoveryToken: string; +} + +export interface RemoveContentRelationPropertyResult extends RelationshipCommitReceipt { + propertyId: string; + relationshipTypeId: string; + removedEdgeIds: string[]; + undo: { revisionId: string; recoveryToken: string }; +} + +export interface ContentRelationshipHistoryEndpoint { + pageId: string; + title: string; +} + +export interface ContentRelationshipHistoryChange { + eventId: string; + kind: "added" | "removed" | "replaced" | "restored"; + relationshipTypeId: string; + relationshipLabel: string; + source: ContentRelationshipHistoryEndpoint; + target: ContentRelationshipHistoryEndpoint; + previousTarget?: ContentRelationshipHistoryEndpoint; +} + +export interface ContentRelationshipHistoryItem { + revisionId: string; + eventIds: string[]; + committedAt: string; + actor: { + kind: "person" | "agent" | "automation" | "programmatic"; + displayName: string; + email?: string; + runId?: string; + networkProtocol?: "a2a" | "mcp" | "provider-api"; + networkId?: string; + networkPeer?: string; + threadId?: string; + turnId?: string; + }; + authorizingPrincipal: Record; + origin: string; + operation: string; + summary: string; + changes: ContentRelationshipHistoryChange[]; + diff: Record; + recovery: { allowed: boolean; recoveryToken?: string }; +} + +export interface ListContentRelationshipHistoryResult { + scope: "viewer-accessible"; + items: ContentRelationshipHistoryItem[]; + nextCursor: string | null; +} + +export interface UndoContentRelationshipRevisionResult extends RelationshipCommitReceipt { + undoneRevisionId: string; + results: RelationshipMutationResultItem[]; + undo: { revisionId: string; recoveryToken: string }; +} From 4b349127bc979f54239e343790ab6286c6aa1d08 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:32:54 -0400 Subject: [PATCH 2/5] Fix relationship pagination, replay, and history indexing --- .../references/typed-relationships.md | 10 + .../content/actions/_relationship-core.ts | 680 ++++++++++++- .../list-content-relation-candidates.ts | 152 +-- .../list-content-relationship-history.ts | 292 +++++- .../actions/mutate-content-relationships.ts | 111 ++- ...lationship-mutation-idempotency.db.test.ts | 458 +++++++++ .../relationship-pagination.db.test.ts | 931 ++++++++++++++++++ ...ionship-revision-document-index.db.test.ts | 437 ++++++++ .../content/scripts/migrate-production.ts | 7 + templates/content/server/db/schema.ts | 25 + templates/content/server/plugins/db.ts | 16 + 11 files changed, 2984 insertions(+), 135 deletions(-) create mode 100644 templates/content/actions/relationship-mutation-idempotency.db.test.ts create mode 100644 templates/content/actions/relationship-pagination.db.test.ts create mode 100644 templates/content/actions/relationship-revision-document-index.db.test.ts diff --git a/templates/content/.agents/skills/document-editing/references/typed-relationships.md b/templates/content/.agents/skills/document-editing/references/typed-relationships.md index 0127354150b..fb7132569d9 100644 --- a/templates/content/.agents/skills/document-editing/references/typed-relationships.md +++ b/templates/content/.agents/skills/document-editing/references/typed-relationships.md @@ -50,3 +50,13 @@ UI and MCP use these same Actions. Generic property setters and bulk row setters cannot overwrite canonical relationship values. Never store endpoint arrays through SQL to bypass this boundary. The exact supported parameters, capabilities and failure codes live in each Action schema. + +## History index upgrades + +The Content release command, `pnpm migrate:production` from `templates/content`, +applies schema migrations and indexes existing relationship revisions in batches +of at most 100. Each revision is indexed transactionally; a failed release can +be retried without rewriting its audit records. Server startup does not backfill +history. If History reports that its index is not ready, report the maintenance +prerequisite rather than claiming there are no changes. An operator must complete +the release command against the intended database before retrying History. diff --git a/templates/content/actions/_relationship-core.ts b/templates/content/actions/_relationship-core.ts index b1ba047bea5..e17b11c9241 100644 --- a/templates/content/actions/_relationship-core.ts +++ b/templates/content/actions/_relationship-core.ts @@ -70,8 +70,19 @@ export interface RelationshipRevisionContext { recoveryToken: string; eventIds: string[]; actor: RelationshipActorContext; + tenant: { ownerEmail: string; orgId: string | null; spaceId: string }; } +type RelationshipEventIndexInput = { + tenant: { ownerEmail: string; orgId: string | null; spaceId: string }; + kind: string; + relationshipTypeId?: string | null; + relationshipTypeVersionId?: string | null; + route?: unknown; + targets?: unknown; + diff?: unknown; +}; + function canonical(value: unknown): string { if (value === undefined) return "null"; if (value === null || typeof value !== "object") return JSON.stringify(value); @@ -506,23 +517,668 @@ export async function createRelationshipRevision( diffJson: JSON.stringify(args.diff), compensatesRevisionId: args.compensatesRevisionId ?? null, }); - return { revisionId, recoveryToken, eventIds: [], actor }; + return { + revisionId, + recoveryToken, + eventIds: [], + actor, + tenant: args.tenant, + }; +} + +function relationshipEventRecord( + value: unknown, + description: string, +): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + relationshipError("UNAVAILABLE", `${description} is malformed.`, { + statusCode: 503, + }); + } + return value as Record; +} + +function relationshipEventString( + record: Record, + key: string, + description: string, +): string { + const value = record[key]; + if (typeof value !== "string" || !value) { + relationshipError("UNAVAILABLE", `${description} is malformed.`, { + statusCode: 503, + }); + } + return value; +} + +function relationshipEventStringArray( + record: Record, + key: string, + description: string, +): string[] { + const value = record[key]; + if ( + !Array.isArray(value) || + value.some((entry) => typeof entry !== "string" || !entry) + ) { + relationshipError("UNAVAILABLE", `${description} is malformed.`, { + statusCode: 503, + }); + } + return value as string[]; +} + +function validateRelationshipEventRoute( + route: unknown, + description: string, +): { kind: string; endpointId: string } { + const record = relationshipEventRecord(route, description); + const kind = relationshipEventString(record, "kind", description); + if (kind === "forward-property") { + relationshipEventString(record, "propertyId", description); + return { + kind, + endpointId: relationshipEventString(record, "sourcePageId", description), + }; + } + if (kind === "inverse-property") { + relationshipEventString(record, "propertyId", description); + return { + kind, + endpointId: relationshipEventString(record, "targetPageId", description), + }; + } + if (kind === "connections-forward") { + return { + kind, + endpointId: relationshipEventString(record, "sourcePageId", description), + }; + } + relationshipError("UNAVAILABLE", `${description} is malformed.`, { + statusCode: 503, + }); +} + +function validateRelationshipProjectionReference( + value: unknown, + relationshipTypeId: string, + description: string, +): { databaseId: string; propertyId: string } { + const record = relationshipEventRecord(value, description); + const propertyId = relationshipEventString(record, "propertyId", description); + const databaseId = relationshipEventString(record, "databaseId", description); + if ( + relationshipEventString(record, "relationshipTypeId", description) !== + relationshipTypeId + ) { + relationshipError("UNAVAILABLE", `${description} is inconsistent.`, { + statusCode: 503, + }); + } + return { databaseId, propertyId }; +} + +function validateRelationshipEventShape(args: RelationshipEventIndexInput) { + if (!args.relationshipTypeId) { + relationshipError( + "UNAVAILABLE", + "A relationship Event is missing its relationship type.", + { statusCode: 503 }, + ); + } + const targets = relationshipEventRecord( + args.targets, + "A relationship Event target", + ); + const diff = relationshipEventRecord(args.diff, "A relationship Event diff"); + const edgeDiffs = new Map< + string, + { added?: boolean; retired?: boolean; displaced?: boolean } + >([ + ["relationship-added", { added: true }], + ["relationship-removed", { retired: true }], + ["relationship-replaced", { added: true, retired: true, displaced: true }], + ["relationship-removed-with-projection", { retired: true }], + ["relationship-add-undone", { retired: true }], + ["relationship-removal-undone", { added: true }], + [ + "relationship-replacement-undone", + { added: true, retired: true, displaced: true }, + ], + ]); + const edgeDiff = edgeDiffs.get(args.kind); + if (edgeDiff) { + const route = validateRelationshipEventRoute( + args.route, + "A relationship Event route", + ); + relationshipEventString( + targets, + "lineageId", + "A relationship Event target", + ); + const sourcePageId = relationshipEventString( + targets, + "sourcePageId", + "A relationship Event target", + ); + const targetPageId = relationshipEventString( + targets, + "targetPageId", + "A relationship Event target", + ); + if ( + route.endpointId !== + (route.kind === "inverse-property" ? targetPageId : sourcePageId) + ) { + relationshipError( + "UNAVAILABLE", + "A relationship Event route is inconsistent with its target.", + { statusCode: 503 }, + ); + } + if (edgeDiff.displaced) { + relationshipEventStringArray( + targets, + "displacedLineageIds", + "A relationship Event target", + ); + } + if (edgeDiff.added) { + relationshipEventStringArray( + diff, + "addedActivationIds", + "A relationship Event diff", + ); + } + if (edgeDiff.retired) { + relationshipEventStringArray( + diff, + "retiredActivationIds", + "A relationship Event diff", + ); + } + return { targets, diff, relationshipTypeId: args.relationshipTypeId }; + } + + if ( + args.kind === "relationship-endpoint-trash" || + args.kind === "relationship-endpoint-restore" || + args.kind === "relationship-endpoint-permanent-delete" + ) { + relationshipEventString( + targets, + "lineageId", + "A relationship Event target", + ); + relationshipEventString( + targets, + "sourcePageId", + "A relationship Event target", + ); + relationshipEventString( + targets, + "targetPageId", + "A relationship Event target", + ); + relationshipEventStringArray( + targets, + "affectedPageIds", + "A relationship Event target", + ); + const expectedState = args.kind.slice("relationship-endpoint-".length); + if (diff.state !== expectedState) { + relationshipError( + "UNAVAILABLE", + "A relationship lifecycle Event is inconsistent.", + { statusCode: 503 }, + ); + } + return { targets, diff, relationshipTypeId: args.relationshipTypeId }; + } + + if (args.kind === "relationship-projection-configured") { + const propertyIds = relationshipEventStringArray( + targets, + "propertyIds", + "A relationship projection Event target", + ); + const databaseIds = relationshipEventStringArray( + targets, + "databaseIds", + "A relationship projection Event target", + ); + if (!Array.isArray(diff.projections) || diff.projections.length === 0) { + relationshipError( + "UNAVAILABLE", + "A relationship projection Event diff is malformed.", + { statusCode: 503 }, + ); + } + const projections = diff.projections.map((projection) => + validateRelationshipProjectionReference( + projection, + args.relationshipTypeId!, + "A relationship projection Event diff", + ), + ); + if ( + JSON.stringify([...new Set(propertyIds)].sort()) !== + JSON.stringify( + [ + ...new Set(projections.map((projection) => projection.propertyId)), + ].sort(), + ) || + JSON.stringify([...new Set(databaseIds)].sort()) !== + JSON.stringify( + [ + ...new Set(projections.map((projection) => projection.databaseId)), + ].sort(), + ) + ) { + relationshipError( + "UNAVAILABLE", + "A relationship projection Event is inconsistent.", + { statusCode: 503 }, + ); + } + return { targets, diff, relationshipTypeId: args.relationshipTypeId }; + } + + if ( + args.kind === "relationship-projection-removed" || + args.kind === "relationship-projection-restored" + ) { + const propertyId = relationshipEventString( + targets, + "propertyId", + "A relationship projection Event target", + ); + const databaseId = relationshipEventString( + targets, + "databaseId", + "A relationship projection Event target", + ); + const projection = validateRelationshipProjectionReference( + diff.projection, + args.relationshipTypeId, + "A relationship projection Event diff", + ); + if ( + projection.propertyId !== propertyId || + projection.databaseId !== databaseId + ) { + relationshipError( + "UNAVAILABLE", + "A relationship projection Event is inconsistent.", + { statusCode: 503 }, + ); + } + return { targets, diff, relationshipTypeId: args.relationshipTypeId }; + } + + relationshipError( + "UNAVAILABLE", + `Relationship Event kind ${JSON.stringify(args.kind)} cannot be indexed safely.`, + { statusCode: 503 }, + ); +} + +function collectRelationshipEventReferences( + value: unknown, + parentKey: string | null, + references: { + documentIds: Set; + databaseIds: Set; + lineageIds: Set; + }, +): void { + if (Array.isArray(value)) { + for (const entry of value) { + collectRelationshipEventReferences(entry, parentKey, references); + } + return; + } + if (value && typeof value === "object") { + for (const [key, entry] of Object.entries(value)) { + collectRelationshipEventReferences(entry, key, references); + } + return; + } + if (typeof value !== "string" || !parentKey) return; + if (/(?:^|_)(?:source|target)?pageids?$/i.test(parentKey)) { + references.documentIds.add(value); + } else if (/(?:^|_)(?:source|target|owner)?databaseids?$/i.test(parentKey)) { + references.databaseIds.add(value); + } else if (/(?:^|_)(?:displaced)?lineageids?$/i.test(parentKey)) { + references.lineageIds.add(value); + } +} + +async function indexRelationshipRevisionDocuments( + tx: RelationshipDb, + revisionId: string, + args: RelationshipEventIndexInput, + mode: "runtime" | "backfill", +): Promise { + const validated = validateRelationshipEventShape(args); + const references = { + documentIds: new Set(), + databaseIds: new Set(), + lineageIds: new Set(), + }; + for (const value of [args.route, validated.targets, validated.diff]) { + collectRelationshipEventReferences(value, null, references); + } + if (args.kind.startsWith("relationship-endpoint-")) { + for (const pageId of relationshipEventStringArray( + validated.targets, + "affectedPageIds", + "A relationship Event target", + )) { + references.documentIds.add(pageId); + } + } + + const lineageIds = [...references.lineageIds].sort(); + const lineages = lineageIds.length + ? await tx + .select() + .from(schema.contentRelationshipLineages) + .where(inArray(schema.contentRelationshipLineages.id, lineageIds)) + : []; + if ( + lineages.length !== lineageIds.length || + lineages.some( + (lineage) => + lineage.spaceId !== args.tenant.spaceId || + lineage.relationshipTypeId !== validated.relationshipTypeId, + ) + ) { + relationshipError( + "UNAVAILABLE", + "A relationship Event lineage reference is incomplete or inconsistent.", + { statusCode: 503 }, + ); + } + for (const lineage of lineages) { + references.documentIds.add(lineage.sourcePageId); + references.documentIds.add(lineage.targetPageId); + } + if ("lineageId" in validated.targets) { + const primaryLineageId = relationshipEventString( + validated.targets, + "lineageId", + "A relationship Event target", + ); + const primaryLineage = lineages.find( + (lineage) => lineage.id === primaryLineageId, + ); + if ( + !primaryLineage || + primaryLineage.sourcePageId !== + relationshipEventString( + validated.targets, + "sourcePageId", + "A relationship Event target", + ) || + primaryLineage.targetPageId !== + relationshipEventString( + validated.targets, + "targetPageId", + "A relationship Event target", + ) + ) { + relationshipError( + "UNAVAILABLE", + "A relationship Event lineage is inconsistent with its endpoints.", + { statusCode: 503 }, + ); + } + } + + const [type] = await tx + .select() + .from(schema.contentRelationshipTypes) + .where( + eq(schema.contentRelationshipTypes.id, validated.relationshipTypeId), + ); + if (!type || type.spaceId !== args.tenant.spaceId) { + relationshipError( + "UNAVAILABLE", + "A relationship Event type reference is incomplete or inconsistent.", + { statusCode: 503 }, + ); + } + const versionIds = [ + ...new Set( + [args.relationshipTypeVersionId, type.currentVersionId].filter( + (versionId): versionId is string => Boolean(versionId), + ), + ), + ]; + const versions = await tx + .select() + .from(schema.contentRelationshipTypeVersions) + .where(inArray(schema.contentRelationshipTypeVersions.id, versionIds)); + if ( + versions.length !== versionIds.length || + versions.some( + (version) => + version.relationshipTypeId !== type.id || + version.spaceId !== args.tenant.spaceId, + ) + ) { + relationshipError( + "UNAVAILABLE", + "A relationship Event type-version reference is incomplete or inconsistent.", + { statusCode: 503 }, + ); + } + for (const version of versions) { + references.databaseIds.add(version.sourceDatabaseId); + references.databaseIds.add(version.targetDatabaseId); + } + + const databaseIds = [...references.databaseIds].sort(); + const databases = await tx + .select() + .from(schema.contentDatabases) + .where(inArray(schema.contentDatabases.id, databaseIds)); + if (databases.some((database) => database.spaceId !== args.tenant.spaceId)) { + relationshipError( + "UNAVAILABLE", + "A relationship Event database reference is inconsistent.", + { statusCode: 503 }, + ); + } + const foundDatabaseIds = new Set(databases.map((database) => database.id)); + const missingDatabaseIds = databaseIds.filter( + (databaseId) => !foundDatabaseIds.has(databaseId), + ); + if (missingDatabaseIds.length > 0 && mode === "runtime") { + relationshipError( + "UNAVAILABLE", + "A relationship Event database reference is incomplete.", + { statusCode: 503 }, + ); + } + for (const database of databases) { + references.documentIds.add(database.documentId); + } + + const documentIds = [...references.documentIds].sort(); + const unresolvedDocumentIds = missingDatabaseIds.map( + (databaseId) => `missing-database:${databaseId}`, + ); + if (documentIds.length === 0 && unresolvedDocumentIds.length === 0) { + relationshipError( + "UNAVAILABLE", + "A relationship Event has no indexable document references.", + { statusCode: 503 }, + ); + } + await tx + .insert(schema.contentRelationshipRevisionDocuments) + .values([ + ...documentIds.map((documentId) => ({ + id: nanoid(24), + ownerEmail: args.tenant.ownerEmail, + orgId: args.tenant.orgId, + spaceId: args.tenant.spaceId, + revisionId, + documentId, + unresolved: 0, + })), + ...unresolvedDocumentIds.map((documentId) => ({ + id: nanoid(24), + ownerEmail: args.tenant.ownerEmail, + orgId: args.tenant.orgId, + spaceId: args.tenant.spaceId, + revisionId, + documentId, + unresolved: 1, + })), + ]) + .onConflictDoNothing({ + target: [ + schema.contentRelationshipRevisionDocuments.revisionId, + schema.contentRelationshipRevisionDocuments.documentId, + schema.contentRelationshipRevisionDocuments.unresolved, + ], + }); +} + +function parseStoredRelationshipEventValue( + value: string, + description: string, +): unknown { + try { + return JSON.parse(value); + } catch { + relationshipError("UNAVAILABLE", `${description} is unreadable.`, { + statusCode: 503, + }); + } +} + +export async function backfillRelationshipRevisionDocuments( + db: RelationshipDb = getDb(), + batchSize = 100, +): Promise<{ processed: number }> { + if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 1_000) { + throw new RangeError( + "Relationship Revision document backfill batch size must be an integer from 1 to 1000.", + ); + } + const revisions = await db + .select({ + id: schema.contentRelationshipRevisions.id, + ownerEmail: schema.contentRelationshipRevisions.ownerEmail, + orgId: schema.contentRelationshipRevisions.orgId, + spaceId: schema.contentRelationshipRevisions.spaceId, + createdAt: schema.contentRelationshipRevisions.createdAt, + }) + .from(schema.contentRelationshipRevisions) + .innerJoin( + schema.contentRelationshipEvents, + eq( + schema.contentRelationshipEvents.revisionId, + schema.contentRelationshipRevisions.id, + ), + ) + .leftJoin( + schema.contentRelationshipRevisionDocuments, + eq( + schema.contentRelationshipRevisionDocuments.revisionId, + schema.contentRelationshipRevisions.id, + ), + ) + .where(isNull(schema.contentRelationshipRevisionDocuments.id)) + .groupBy( + schema.contentRelationshipRevisions.id, + schema.contentRelationshipRevisions.ownerEmail, + schema.contentRelationshipRevisions.orgId, + schema.contentRelationshipRevisions.spaceId, + schema.contentRelationshipRevisions.createdAt, + ) + .orderBy( + schema.contentRelationshipRevisions.createdAt, + schema.contentRelationshipRevisions.id, + ) + .limit(batchSize); + for (const revision of revisions) { + await db.transaction(async (rawTx) => { + const tx = rawTx as unknown as RelationshipDb; + const events = await tx + .select() + .from(schema.contentRelationshipEvents) + .where(eq(schema.contentRelationshipEvents.revisionId, revision.id)) + .orderBy(schema.contentRelationshipEvents.sequence); + for (const event of events) { + if ( + event.ownerEmail !== revision.ownerEmail || + event.orgId !== revision.orgId || + event.spaceId !== revision.spaceId + ) { + relationshipError( + "UNAVAILABLE", + "A relationship Event tenant is inconsistent with its Revision.", + { statusCode: 503 }, + ); + } + await indexRelationshipRevisionDocuments( + tx, + revision.id, + { + tenant: { + ownerEmail: revision.ownerEmail, + orgId: revision.orgId, + spaceId: revision.spaceId, + }, + kind: event.kind, + relationshipTypeId: event.relationshipTypeId, + relationshipTypeVersionId: event.relationshipTypeVersionId, + route: parseStoredRelationshipEventValue( + event.routeJson, + "A relationship Event route", + ), + targets: parseStoredRelationshipEventValue( + event.targetsJson, + "A relationship Event target", + ), + diff: parseStoredRelationshipEventValue( + event.diffJson, + "A relationship Event diff", + ), + }, + "backfill", + ); + } + }); + } + return { processed: revisions.length }; } export async function appendRelationshipEvent( tx: RelationshipDb, revision: RelationshipRevisionContext, - args: { - tenant: { ownerEmail: string; orgId: string | null; spaceId: string }; - kind: string; - relationshipTypeId?: string | null; - relationshipTypeVersionId?: string | null; - route?: unknown; - targets?: unknown; - diff?: unknown; + args: RelationshipEventIndexInput & { eventId?: string; }, ): Promise { + if ( + args.tenant.ownerEmail !== revision.tenant.ownerEmail || + args.tenant.orgId !== revision.tenant.orgId || + args.tenant.spaceId !== revision.tenant.spaceId + ) { + relationshipError( + "UNAVAILABLE", + "A relationship Event tenant is inconsistent with its Revision.", + { statusCode: 503 }, + ); + } const eventId = args.eventId ?? nanoid(24); await tx.insert(schema.contentRelationshipEvents).values({ id: eventId, @@ -544,6 +1200,12 @@ export async function appendRelationshipEvent( targetsJson: JSON.stringify(args.targets ?? {}), diffJson: JSON.stringify(args.diff ?? {}), }); + await indexRelationshipRevisionDocuments( + tx, + revision.revisionId, + args, + "runtime", + ); revision.eventIds.push(eventId); return eventId; } diff --git a/templates/content/actions/list-content-relation-candidates.ts b/templates/content/actions/list-content-relation-candidates.ts index 869343432c0..5d5900b2a80 100644 --- a/templates/content/actions/list-content-relation-candidates.ts +++ b/templates/content/actions/list-content-relation-candidates.ts @@ -1,5 +1,16 @@ import { defineAction, type ActionRunContext } from "@agent-native/core/action"; -import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; +import { accessFilter } from "@agent-native/core/sharing"; +import { + and, + asc, + eq, + inArray, + isNotNull, + isNull, + notExists, + or, + sql, +} from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; import { @@ -9,16 +20,22 @@ import { type ListContentRelationCandidatesResult, } from "../shared/relationships.js"; import { resolveContentDocumentAccess } from "./_content-document-access.js"; +import { listContentOrganizationMemberships } from "./_content-space-access.js"; import { activeActivationIdsForLineages, decodeRelationshipCursor, encodeRelationshipCursor, loadRelationshipDatabase, loadRelationshipTypeBundle, + relationshipActorContext, relationshipError, } from "./_relationship-core.js"; import { issueRelationshipObservation } from "./_relationship-read.js"; +function escapeLike(value: string): string { + return value.replace(/([\\%_])/g, "\\$1"); +} + async function slotObservation( args: { relationshipTypeId: string; @@ -131,78 +148,74 @@ async function listContentRelationCandidates( ? bundle.version.targetDatabaseId : bundle.version.sourceDatabaseId; await loadRelationshipDatabase(candidateDatabaseId, "viewer", db); - const memberships = await db - .select({ documentId: schema.contentDatabaseItems.documentId }) - .from(schema.contentDatabaseItems) - .where(eq(schema.contentDatabaseItems.databaseId, candidateDatabaseId)); - const permanentlyDeleted = memberships.length - ? await db - .select({ pageId: schema.contentRelationshipEndpointStates.pageId }) - .from(schema.contentRelationshipEndpointStates) - .where( - and( - inArray( - schema.contentRelationshipEndpointStates.pageId, - memberships.map((membership) => membership.documentId), - ), - isNotNull( - schema.contentRelationshipEndpointStates.permanentlyDeletedAt, - ), - ), - ) - : []; - const deletedIds = new Set(permanentlyDeleted.map((row) => row.pageId)); - const accessibleIds: string[] = []; - for (const membership of memberships) { - if ( - membership.documentId !== input.anchorPageId && - !deletedIds.has(membership.documentId) && - (await resolveContentDocumentAccess(membership.documentId)) - ) { - accessibleIds.push(membership.documentId); - } - } - if (accessibleIds.length === 0) { - return { - scope: "viewer-accessible", - items: [], - slotObservationToken: - projection.direction === "forward" && - bundle.version.forwardCardinality === "one" - ? await slotObservation( - { - relationshipTypeId: bundle.type.id, - sourcePageId: input.anchorPageId, - ownerEmail: bundle.type.ownerEmail, - orgId: bundle.type.orgId, - spaceId: bundle.type.spaceId, - }, - context, - ) - : null, - nextCursor: null, - }; - } + const actor = relationshipActorContext(context); + const organizationMemberships = await listContentOrganizationMemberships( + actor.userEmail, + ); + const accessContexts = [ + actor.orgId, + ...organizationMemberships.map((m) => m.orgId), + ] + .filter((orgId, index, values) => values.indexOf(orgId) === index) + .map((orgId) => ({ + userEmail: actor.userEmail, + ...(orgId ? { orgId } : {}), + })); + const permanentlyDeletedEndpoint = db + .select({ pageId: schema.contentRelationshipEndpointStates.pageId }) + .from(schema.contentRelationshipEndpointStates) + .where( + and( + eq( + schema.contentRelationshipEndpointStates.pageId, + schema.documents.id, + ), + isNotNull( + schema.contentRelationshipEndpointStates.permanentlyDeletedAt, + ), + ), + ); + const searchPattern = input.search ? `%${escapeLike(input.search)}%` : null; + const offset = decodeRelationshipCursor(input.cursor); const documents = await db .select({ id: schema.documents.id, title: schema.documents.title, - trashedAt: schema.documents.trashedAt, }) - .from(schema.documents) - .where(inArray(schema.documents.id, accessibleIds)); - const search = input.search.toLocaleLowerCase(); - const filtered = documents - .filter( - (document) => - !document.trashedAt && - (!search || document.title.toLocaleLowerCase().includes(search)), + .from(schema.contentDatabaseItems) + .innerJoin( + schema.documents, + eq(schema.documents.id, schema.contentDatabaseItems.documentId), ) - .sort( - (left, right) => - left.title.localeCompare(right.title) || - left.id.localeCompare(right.id), - ); + .where( + and( + eq(schema.contentDatabaseItems.databaseId, candidateDatabaseId), + sql`${schema.documents.id} <> ${input.anchorPageId}`, + isNull(schema.documents.trashedAt), + notExists(permanentlyDeletedEndpoint), + or( + ...accessContexts.map((accessContext) => + accessFilter( + schema.documents, + schema.documentShares, + accessContext, + "viewer", + { includePublic: true }, + ), + ), + ), + searchPattern + ? sql`lower(${schema.documents.title}) LIKE lower(${searchPattern}) ESCAPE '\\'` + : undefined, + ), + ) + .orderBy( + asc(sql`lower(${schema.documents.title})`), + asc(schema.documents.title), + asc(schema.documents.id), + ) + .limit(input.limit + 1) + .offset(offset); const contextDefinitions = input.contextPropertyIds.length ? await db .select({ @@ -235,8 +248,7 @@ async function listContentRelationCandidates( "Relation Properties cannot be returned as raw candidate context. Request ordinary context Properties instead.", ); } - const offset = decodeRelationshipCursor(input.cursor); - const page = filtered.slice(offset, offset + input.limit); + const page = documents.slice(0, input.limit); const valueRows = page.length && contextDefinitions.length ? await db @@ -315,7 +327,7 @@ async function listContentRelationCandidates( ) : null, nextCursor: - offset + page.length < filtered.length + documents.length > input.limit ? encodeRelationshipCursor(offset + page.length) : null, }; diff --git a/templates/content/actions/list-content-relationship-history.ts b/templates/content/actions/list-content-relationship-history.ts index 9fc012802f2..5341b2b14de 100644 --- a/templates/content/actions/list-content-relationship-history.ts +++ b/templates/content/actions/list-content-relationship-history.ts @@ -3,7 +3,8 @@ import { isActionContractError, type ActionRunContext, } from "@agent-native/core/action"; -import { desc, eq, inArray } from "drizzle-orm"; +import { accessFilter } from "@agent-native/core/sharing"; +import { and, desc, eq, exists, inArray, lt, notExists, or } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -15,11 +16,11 @@ import { type ListContentRelationshipHistoryInput, type ListContentRelationshipHistoryResult, } from "../shared/relationships.js"; +import { listContentOrganizationMemberships } from "./_content-space-access.js"; import { - decodeRelationshipCursor, - encodeRelationshipCursor, loadRelationshipDatabase, loadRelationshipTypeBundle, + relationshipActorContext, relationshipError, resolveRelationshipDocumentAccess, } from "./_relationship-core.js"; @@ -59,6 +60,35 @@ const edgeChangeKinds = { type EdgeChangeEventKind = keyof typeof edgeChangeKinds; +type HistoryCursor = { createdAt: string; revisionId: string }; + +function encodeHistoryCursor(cursor: HistoryCursor): string { + return Buffer.from(JSON.stringify({ v: 2, ...cursor }), "utf8").toString( + "base64url", + ); +} + +function decodeHistoryCursor(cursor: string | undefined): HistoryCursor | null { + if (!cursor) return null; + try { + const parsed = JSON.parse( + Buffer.from(cursor, "base64url").toString("utf8"), + ) as { v?: unknown; createdAt?: unknown; revisionId?: unknown }; + if ( + parsed.v !== 2 || + typeof parsed.createdAt !== "string" || + !parsed.createdAt || + typeof parsed.revisionId !== "string" || + !parsed.revisionId + ) { + throw new Error("invalid cursor"); + } + return { createdAt: parsed.createdAt, revisionId: parsed.revisionId }; + } catch { + relationshipError("INVALID_TARGET", "The relationship cursor is invalid."); + } +} + function parseRecord( value: string, description: string, @@ -258,17 +288,214 @@ async function listContentRelationshipHistory( { statusCode: 404 }, ); } - const revisions = directRevision - ? [directRevision] - : await db - .select() - .from(schema.contentRelationshipRevisions) - .where(eq(schema.contentRelationshipRevisions.spaceId, spaceId)) - .orderBy( - desc(schema.contentRelationshipRevisions.createdAt), - desc(schema.contentRelationshipRevisions.id), - ); - const revisionIds = revisions.map((revision) => revision.id); + const actor = relationshipActorContext(context); + const organizationMemberships = await listContentOrganizationMemberships( + actor.userEmail, + ); + const accessContexts = [ + actor.orgId, + ...organizationMemberships.map((membership) => membership.orgId), + ] + .filter((orgId, index, values) => values.indexOf(orgId) === index) + .map((orgId) => ({ + userEmail: actor.userEmail, + ...(orgId ? { orgId } : {}), + })); + const accessibleMappedDocument = db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + eq( + schema.documents.id, + schema.contentRelationshipRevisionDocuments.documentId, + ), + or( + ...accessContexts.map((accessContext) => + accessFilter( + schema.documents, + schema.documentShares, + accessContext, + "viewer", + { includePublic: true }, + ), + ), + ), + ), + ); + const inaccessibleReference = db + .select({ id: schema.contentRelationshipRevisionDocuments.id }) + .from(schema.contentRelationshipRevisionDocuments) + .where( + and( + eq( + schema.contentRelationshipRevisionDocuments.revisionId, + schema.contentRelationshipRevisions.id, + ), + or( + eq(schema.contentRelationshipRevisionDocuments.unresolved, 1), + notExists(accessibleMappedDocument), + ), + ), + ); + const hasMappedDocument = db + .select({ id: schema.contentRelationshipRevisionDocuments.id }) + .from(schema.contentRelationshipRevisionDocuments) + .where( + eq( + schema.contentRelationshipRevisionDocuments.revisionId, + schema.contentRelationshipRevisions.id, + ), + ); + if (directRevision) { + const [accessibleDirectRevision] = await db + .select({ id: schema.contentRelationshipRevisions.id }) + .from(schema.contentRelationshipRevisions) + .where( + and( + eq(schema.contentRelationshipRevisions.id, directRevision.id), + exists(hasMappedDocument), + notExists(inaccessibleReference), + ), + ) + .limit(1); + if (!accessibleDirectRevision) { + relationshipError( + "NOT_ACCESSIBLE", + "The requested relationship history is not accessible.", + { statusCode: 404 }, + ); + } + } + const readinessEvent = db + .select({ id: schema.contentRelationshipEvents.id }) + .from(schema.contentRelationshipEvents) + .where( + and( + eq( + schema.contentRelationshipEvents.revisionId, + schema.contentRelationshipRevisions.id, + ), + input.relationshipTypeId + ? eq( + schema.contentRelationshipEvents.relationshipTypeId, + input.relationshipTypeId, + ) + : undefined, + ), + ); + const anyRevisionEvent = db + .select({ id: schema.contentRelationshipEvents.id }) + .from(schema.contentRelationshipEvents) + .where( + eq( + schema.contentRelationshipEvents.revisionId, + schema.contentRelationshipRevisions.id, + ), + ); + const readinessScope = and( + eq(schema.contentRelationshipRevisions.spaceId, spaceId), + directRevision + ? eq(schema.contentRelationshipRevisions.id, directRevision.id) + : undefined, + ); + const [[unindexedRevision], [emptyRevision]] = await Promise.all([ + db + .select({ id: schema.contentRelationshipRevisions.id }) + .from(schema.contentRelationshipRevisions) + .where( + and( + readinessScope, + exists(readinessEvent), + notExists(hasMappedDocument), + ), + ) + .limit(1), + db + .select({ id: schema.contentRelationshipRevisions.id }) + .from(schema.contentRelationshipRevisions) + .where(and(readinessScope, notExists(anyRevisionEvent))) + .limit(1), + ]); + if (unindexedRevision || emptyRevision) { + relationshipError( + "UNAVAILABLE", + "Relationship history is not ready. Run the relationship history index migration and retry.", + { statusCode: 503 }, + ); + } + const matchingEvent = db + .select({ id: schema.contentRelationshipEvents.id }) + .from(schema.contentRelationshipEvents) + .where( + and( + eq( + schema.contentRelationshipEvents.revisionId, + schema.contentRelationshipRevisions.id, + ), + input.relationshipTypeId + ? eq( + schema.contentRelationshipEvents.relationshipTypeId, + input.relationshipTypeId, + ) + : undefined, + ), + ); + const matchingPage = input.pageId + ? db + .select({ id: schema.contentRelationshipRevisionDocuments.id }) + .from(schema.contentRelationshipRevisionDocuments) + .where( + and( + eq( + schema.contentRelationshipRevisionDocuments.revisionId, + schema.contentRelationshipRevisions.id, + ), + eq( + schema.contentRelationshipRevisionDocuments.documentId, + input.pageId, + ), + ), + ) + : null; + const cursor = decodeHistoryCursor(input.cursor); + const revisionWhere = and( + eq(schema.contentRelationshipRevisions.spaceId, spaceId), + directRevision + ? eq(schema.contentRelationshipRevisions.id, directRevision.id) + : undefined, + exists(hasMappedDocument), + notExists(inaccessibleReference), + exists(matchingEvent), + matchingPage ? exists(matchingPage) : undefined, + !directRevision && cursor + ? or( + lt(schema.contentRelationshipRevisions.createdAt, cursor.createdAt), + and( + eq(schema.contentRelationshipRevisions.createdAt, cursor.createdAt), + lt(schema.contentRelationshipRevisions.id, cursor.revisionId), + ), + ) + : undefined, + ); + const revisions = await db + .select() + .from(schema.contentRelationshipRevisions) + .where(revisionWhere) + .orderBy( + desc(schema.contentRelationshipRevisions.createdAt), + desc(schema.contentRelationshipRevisions.id), + ) + .limit(directRevision ? 1 : input.limit + 1); + if (directRevision && revisions.length === 0) { + relationshipError( + "NOT_ACCESSIBLE", + "The requested relationship history is not accessible.", + { statusCode: 404 }, + ); + } + const pageRevisions = revisions.slice(0, input.limit); + const revisionIds = pageRevisions.map((revision) => revision.id); const events = revisionIds.length ? await db .select() @@ -307,35 +534,13 @@ async function listContentRelationshipHistory( ), ); const authorized: ContentRelationshipHistoryItem[] = []; - for (const revision of revisions) { + for (const revision of pageRevisions) { const revisionEvents = [...(eventsByRevision.get(revision.id) ?? [])].sort( (left, right) => left.sequence - right.sequence || left.id.localeCompare(right.id), ); if (revisionEvents.length === 0) continue; - if ( - input.relationshipTypeId && - !revisionEvents.some( - (event) => event.relationshipTypeId === input.relationshipTypeId, - ) - ) { - continue; - } - const typeIds = [ - ...new Set( - revisionEvents.flatMap((event) => - event.relationshipTypeId ? [event.relationshipTypeId] : [], - ), - ), - ]; let accessible = true; - for (const typeId of typeIds) { - if (!(await typeIsAccessible(typeId, context))) { - accessible = false; - break; - } - } - if (!accessible) continue; const eventRecords = revisionEvents.map((event) => ({ event, targets: parseRecord(event.targetsJson, "A relationship event target"), @@ -452,7 +657,6 @@ async function listContentRelationshipHistory( ]), ]), ]; - if (input.pageId && !pageIds.includes(input.pageId)) continue; const endpoints = new Map(); for (const pageId of pageIds) { const access = await resolveRelationshipDocumentAccess(pageId, { @@ -623,14 +827,16 @@ async function listContentRelationshipHistory( { statusCode: 404 }, ); } - const offset = decodeRelationshipCursor(input.cursor); - const page = authorized.slice(offset, offset + input.limit); + const lastRevision = pageRevisions[pageRevisions.length - 1]; return { scope: "viewer-accessible", - items: page, + items: authorized, nextCursor: - offset + page.length < authorized.length - ? encodeRelationshipCursor(offset + page.length) + !directRevision && revisions.length > input.limit && lastRevision + ? encodeHistoryCursor({ + createdAt: lastRevision.createdAt, + revisionId: lastRevision.id, + }) : null, }; } diff --git a/templates/content/actions/mutate-content-relationships.ts b/templates/content/actions/mutate-content-relationships.ts index bb04beba817..cf2ea29cbea 100644 --- a/templates/content/actions/mutate-content-relationships.ts +++ b/templates/content/actions/mutate-content-relationships.ts @@ -112,17 +112,88 @@ async function assertRelationshipReceiptAccessible( } } -function deduplicateChanges( - changes: RelationshipChange[], -): RelationshipChange[] { +function normalizeChanges(changes: RelationshipChange[]): RelationshipChange[] { const seenAdds = new Set(); - return changes.filter((change) => { - if (change.kind !== "add") return true; - const key = `${change.typeId}\u0000${change.sourcePageId}\u0000${change.targetPageId}`; - if (seenAdds.has(key)) return false; - seenAdds.add(key); - return true; - }); + const seenRemoves = new Map(); + const normalized: RelationshipChange[] = []; + for (const change of changes) { + if (change.kind === "add") { + const key = `${change.typeId}\u0000${change.sourcePageId}\u0000${change.targetPageId}`; + if (seenAdds.has(key)) continue; + seenAdds.add(key); + normalized.push(change); + continue; + } + if (change.kind !== "remove") { + normalized.push(change); + continue; + } + const normalizedRemove = { + ...change, + observedActivationIds: [...new Set(change.observedActivationIds)].sort(), + }; + const signature = relationshipRequestHash(normalizedRemove); + const previousSignature = seenRemoves.get(change.edgeId); + if (previousSignature === signature) continue; + if (previousSignature) { + relationshipError( + "STALE_SELECTION", + "One relationship cannot be removed with conflicting observations in the same mutation.", + { statusCode: 409 }, + ); + } + seenRemoves.set(change.edgeId, signature); + normalized.push(normalizedRemove); + } + return normalized; +} + +async function resolveReceiptSpaceId( + changes: RelationshipChange[], + db: RelationshipDb, +): Promise { + const typeIds = [ + ...new Set( + changes.flatMap((change) => + change.kind === "remove" ? [] : [change.typeId], + ), + ), + ]; + const edgeIds = [ + ...new Set( + changes.flatMap((change) => + change.kind === "remove" ? [change.edgeId] : [], + ), + ), + ]; + const [types, lineages] = await Promise.all([ + typeIds.length + ? db + .select({ + id: schema.contentRelationshipTypes.id, + spaceId: schema.contentRelationshipTypes.spaceId, + }) + .from(schema.contentRelationshipTypes) + .where(inArray(schema.contentRelationshipTypes.id, typeIds)) + : [], + edgeIds.length + ? db + .select({ + id: schema.contentRelationshipLineages.id, + spaceId: schema.contentRelationshipLineages.spaceId, + }) + .from(schema.contentRelationshipLineages) + .where(inArray(schema.contentRelationshipLineages.id, edgeIds)) + : [], + ]); + if (types.length !== typeIds.length || lineages.length !== edgeIds.length) { + return null; + } + const spaceIds = new Set([ + ...types.map((type) => type.spaceId), + ...lineages.map((lineage) => lineage.spaceId), + ]); + return spaceIds.size === 1 ? [...spaceIds][0]! : null; } async function planChanges( @@ -393,8 +464,24 @@ async function mutateContentRelationships( input: MutateContentRelationshipsInput, context?: ActionRunContext, ): Promise { - const changes = deduplicateChanges(input.changes); + const actor = relationshipActorContext(context); + const changes = normalizeChanges(input.changes); const db = getDb(); + const requestHash = relationshipRequestHash({ ...input, changes }); + const receiptSpaceId = await resolveReceiptSpaceId(changes, db); + if (receiptSpaceId) { + const replayed = + await replayRelationshipReceipt(db, { + spaceId: receiptSpaceId, + operationId: input.operationId, + requestHash, + context, + }); + if (replayed) { + await assertRelationshipReceiptAccessible(db, replayed, context); + return replayed; + } + } let plans = await planChanges(changes, db); validateBatchShape(plans); const preflightRoutes: AuthorizedRelationshipRoute[] = []; @@ -411,8 +498,6 @@ async function mutateContentRelationships( }), ); } - const requestHash = relationshipRequestHash({ ...input, changes }); - const actor = relationshipActorContext(context); const firstBundle = plans[0]!.bundle; const tenant = { ownerEmail: firstBundle.type.ownerEmail, diff --git a/templates/content/actions/relationship-mutation-idempotency.db.test.ts b/templates/content/actions/relationship-mutation-idempotency.db.test.ts new file mode 100644 index 00000000000..93418cd97cd --- /dev/null +++ b/templates/content/actions/relationship-mutation-idempotency.db.test.ts @@ -0,0 +1,458 @@ +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runWithRequestContext } from "@agent-native/core/server"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const databasePath = join( + tmpdir(), + `relationship-mutation-idempotency-${process.pid}-${Date.now()}.pglite`, +); +const owner = "relationship-mutation-owner@example.test"; +const viewer = "relationship-mutation-viewer@example.test"; +const spaceId = `relationship-mutation-${process.pid}-${Date.now()}`; + +let dbModule: typeof import("../server/db/index.js"); +let configure: typeof import("./configure-content-relation-property.js").default; +let listRelationships: typeof import("./list-content-relationships.js").default; +let mutate: typeof import("./mutate-content-relationships.js").default; +let nextFixture = 0; + +const asUser = (userEmail: string, run: () => Promise) => + runWithRequestContext({ userEmail }, run); +const asOwner = (run: () => Promise) => asUser(owner, run); +const asViewer = (run: () => Promise) => asUser(viewer, run); + +beforeAll(async () => { + process.env.DATABASE_URL = `pglite:${databasePath}`; + dbModule = await import("../server/db/index.js"); + await (await import("../server/plugins/db.js")).default(undefined as never); + configure = (await import("./configure-content-relation-property.js")) + .default; + listRelationships = (await import("./list-content-relationships.js")).default; + mutate = (await import("./mutate-content-relationships.js")).default; + + const filesDatabaseId = `${spaceId}-files`; + await dbModule.getDb().insert(dbModule.schema.contentSpaces).values({ + id: spaceId, + name: "Relationship mutation idempotency", + kind: "personal", + ownerEmail: owner, + filesDatabaseId, + createdBy: owner, + }); + await dbModule + .getDb() + .insert(dbModule.schema.documents) + .values({ + id: `${filesDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Files", + }); + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabases) + .values({ + id: filesDatabaseId, + documentId: `${filesDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Files", + systemRole: "files", + blocksSeeded: 1, + }); +}, 60_000); + +afterAll(() => { + delete process.env.DATABASE_URL; + rmSync(databasePath, { recursive: true, force: true }); +}); + +async function fixture() { + const prefix = `${spaceId}-${++nextFixture}`; + const sourceDatabaseId = `${prefix}-deliverables`; + const targetDatabaseId = `${prefix}-people`; + const sourcePageId = `${prefix}-launch`; + const targetPageIds = [`${prefix}-mira`, `${prefix}-jo`]; + await dbModule + .getDb() + .insert(dbModule.schema.documents) + .values([ + { + id: `${sourceDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Campaign deliverables", + }, + { + id: `${targetDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Marketing team", + }, + { + id: sourcePageId, + spaceId, + ownerEmail: owner, + title: "Launch article", + }, + ...targetPageIds.map((id, index) => ({ + id, + spaceId, + ownerEmail: owner, + title: index === 0 ? "Mira" : "Jo", + })), + ]); + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabases) + .values([ + { + id: sourceDatabaseId, + spaceId, + ownerEmail: owner, + documentId: `${sourceDatabaseId}-page`, + title: "Deliverables", + blocksSeeded: 1, + }, + { + id: targetDatabaseId, + spaceId, + ownerEmail: owner, + documentId: `${targetDatabaseId}-page`, + title: "People", + blocksSeeded: 1, + }, + ]); + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabaseItems) + .values([ + { + id: `${prefix}-source-item`, + databaseId: sourceDatabaseId, + documentId: sourcePageId, + ownerEmail: owner, + }, + ...targetPageIds.map((documentId, index) => ({ + id: `${prefix}-target-item-${index}`, + databaseId: targetDatabaseId, + documentId, + ownerEmail: owner, + position: index, + })), + ]); + const configured = await asOwner(() => + configure.run({ + ownerDatabaseId: sourceDatabaseId, + alias: "Contributors", + operationId: `${prefix}-configure`, + definition: { + kind: "new-local", + forwardLabel: "Contributes to", + inverseLabel: "Deliverables", + forwardCardinality: "many", + sourceDatabaseId, + targetDatabaseId, + }, + inverseProjection: { + ownerDatabaseId: targetDatabaseId, + alias: "Deliverables", + editable: true, + }, + }), + ); + return { + prefix, + sourceDatabaseId, + targetDatabaseId, + sourcePageId, + targetPageIds, + typeId: configured.relationshipType.id, + typeVersionId: configured.relationshipTypeVersion.id, + propertyId: configured.projection.propertyId, + }; +} + +type Fixture = Awaited>; + +function addInput( + seed: Fixture, + operationId: string, + targetPageId = seed.targetPageIds[0]!, +) { + return { + operationId, + changes: [ + { + kind: "add" as const, + typeId: seed.typeId, + typeVersionId: seed.typeVersionId, + sourcePageId: seed.sourcePageId, + targetPageId, + route: { + kind: "forward-property" as const, + propertyId: seed.propertyId, + sourcePageId: seed.sourcePageId, + }, + }, + ], + }; +} + +async function outgoing(seed: Fixture) { + return asOwner(() => + listRelationships.run({ + pageId: seed.sourcePageId, + relationshipTypeId: seed.typeId, + direction: "outgoing", + }), + ); +} + +async function applyDrift( + seed: Fixture, + drift: "archive" | "membership" | "trash" | "version", +) { + if (drift === "archive") { + const archivedAt = new Date().toISOString(); + await dbModule + .getDb() + .update(dbModule.schema.contentRelationshipTypes) + .set({ state: "archived", archivedAt, updatedAt: archivedAt }) + .where(eq(dbModule.schema.contentRelationshipTypes.id, seed.typeId)); + return; + } + if (drift === "membership") { + await dbModule + .getDb() + .delete(dbModule.schema.contentDatabaseItems) + .where( + and( + eq( + dbModule.schema.contentDatabaseItems.databaseId, + seed.targetDatabaseId, + ), + eq( + dbModule.schema.contentDatabaseItems.documentId, + seed.targetPageIds[0]!, + ), + ), + ); + return; + } + if (drift === "trash") { + await dbModule + .getDb() + .update(dbModule.schema.documents) + .set({ trashedAt: new Date().toISOString() }) + .where(eq(dbModule.schema.documents.id, seed.targetPageIds[0]!)); + return; + } + const [current] = await dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipTypeVersions) + .where( + eq( + dbModule.schema.contentRelationshipTypeVersions.id, + seed.typeVersionId, + ), + ); + const nextVersionId = `${seed.typeId}-version-2`; + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipTypeVersions) + .values({ + ...current!, + id: nextVersionId, + version: current!.version + 1, + forwardLabel: "Updated contribution", + }); + await dbModule + .getDb() + .update(dbModule.schema.contentRelationshipTypes) + .set({ + currentVersionId: nextVersionId, + updatedAt: new Date().toISOString(), + }) + .where(eq(dbModule.schema.contentRelationshipTypes.id, seed.typeId)); +} + +describe("typed relationship mutation idempotency", () => { + it.each(["archive", "membership", "trash", "version"] as const)( + "replays a committed receipt after %s drift", + async (drift) => { + const seed = await fixture(); + const input = addInput(seed, `${seed.prefix}-add`); + const committed = await asOwner(() => mutate.run(input)); + + await applyDrift(seed, drift); + + await expect(asOwner(() => mutate.run(input))).resolves.toEqual( + committed, + ); + }, + ); + + it("returns an idempotency conflict before changed state rejects the request", async () => { + const seed = await fixture(); + const input = addInput(seed, `${seed.prefix}-add`); + await asOwner(() => mutate.run(input)); + await applyDrift(seed, "archive"); + + await expect( + asOwner(() => + mutate.run({ + ...input, + changes: [ + { + ...input.changes[0]!, + targetPageId: seed.targetPageIds[1]!, + }, + ], + }), + ), + ).rejects.toMatchObject({ errorCode: "IDEMPOTENCY_CONFLICT" }); + }); + + it("rechecks current access before returning an early receipt", async () => { + const seed = await fixture(); + const shares = [ + { + resourceId: `${seed.sourceDatabaseId}-page`, + role: "editor", + }, + { resourceId: seed.sourcePageId, role: "editor" }, + { + resourceId: `${seed.targetDatabaseId}-page`, + role: "viewer", + }, + { resourceId: seed.targetPageIds[0]!, role: "viewer" }, + ] as const; + await dbModule + .getDb() + .insert(dbModule.schema.documentShares) + .values( + shares.map(({ resourceId, role }, index) => ({ + id: `${seed.prefix}-viewer-share-${index}`, + resourceId, + principalType: "user", + principalId: viewer, + role, + createdBy: owner, + })), + ); + const input = addInput(seed, `${seed.prefix}-viewer-add`); + await asViewer(() => mutate.run(input)); + await dbModule + .getDb() + .delete(dbModule.schema.documentShares) + .where( + and( + eq(dbModule.schema.documentShares.resourceId, seed.targetPageIds[0]!), + eq(dbModule.schema.documentShares.principalId, viewer), + ), + ); + + await expect(asViewer(() => mutate.run(input))).rejects.toMatchObject({ + errorCode: "NOT_ACCESSIBLE", + message: "The requested Content object is not accessible.", + }); + }); + + it("canonicalizes equivalent removals before hashing and recording history", async () => { + const seed = await fixture(); + await asOwner(() => + mutate.run(addInput(seed, `${seed.prefix}-add-before-remove`)), + ); + await asOwner(() => + mutate.run(addInput(seed, `${seed.prefix}-assert-before-remove`)), + ); + const observed = (await outgoing(seed)).items[0]!; + const canonicalActivationIds = [...observed.observedActivationIds].sort(); + const noisyActivationIds = [ + ...[...canonicalActivationIds].reverse(), + canonicalActivationIds[0]!, + ]; + const change = { + kind: "remove" as const, + edgeId: observed.edgeId, + observedActivationIds: noisyActivationIds, + observationToken: observed.observationToken, + route: observed.routes[0]!, + }; + const operationId = `${seed.prefix}-remove`; + const removed = await asOwner(() => + mutate.run({ operationId, changes: [change, { ...change }] }), + ); + + expect(removed.results).toHaveLength(1); + const events = await dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipEvents) + .where( + eq( + dbModule.schema.contentRelationshipEvents.revisionId, + removed.revisionId, + ), + ); + expect(events).toHaveLength(1); + expect(JSON.parse(events[0]!.diffJson)).toEqual({ + retiredActivationIds: canonicalActivationIds, + }); + await expect( + asOwner(() => + mutate.run({ + operationId, + changes: [ + { ...change, observedActivationIds: canonicalActivationIds }, + ], + }), + ), + ).resolves.toEqual(removed); + }); + + it("rejects conflicting repeated removal observations before committing", async () => { + const seed = await fixture(); + await asOwner(() => + mutate.run(addInput(seed, `${seed.prefix}-add-before-conflict`)), + ); + const observed = (await outgoing(seed)).items[0]!; + const change = { + kind: "remove" as const, + edgeId: observed.edgeId, + observedActivationIds: observed.observedActivationIds, + observationToken: observed.observationToken, + route: observed.routes[0]!, + }; + const operationId = `${seed.prefix}-conflicting-remove`; + + await expect( + asOwner(() => + mutate.run({ + operationId, + changes: [ + change, + { ...change, observationToken: `${change.observationToken}-other` }, + ], + }), + ), + ).rejects.toMatchObject({ errorCode: "STALE_SELECTION" }); + const revisions = await dbModule + .getDb() + .select({ id: dbModule.schema.contentRelationshipRevisions.id }) + .from(dbModule.schema.contentRelationshipRevisions) + .where( + eq( + dbModule.schema.contentRelationshipRevisions.operationId, + operationId, + ), + ); + expect(revisions).toHaveLength(0); + }); +}); diff --git a/templates/content/actions/relationship-pagination.db.test.ts b/templates/content/actions/relationship-pagination.db.test.ts new file mode 100644 index 00000000000..bf52513d4fa --- /dev/null +++ b/templates/content/actions/relationship-pagination.db.test.ts @@ -0,0 +1,931 @@ +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runWithRequestContext } from "@agent-native/core/server"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +const databasePath = join( + tmpdir(), + `relationship-pagination-${process.pid}-${Date.now()}.pglite`, +); +const owner = "relationship-pagination-owner@example.test"; +const viewer = "relationship-pagination-viewer@example.test"; +const prefix = `relationship-pagination-${process.pid}-${Date.now()}`; +const spaceId = `${prefix}-space`; +const sourceDatabaseId = `${prefix}-deliverables`; +const targetDatabaseId = `${prefix}-people`; +const sourceDatabasePageId = `${sourceDatabaseId}-page`; +const targetDatabasePageId = `${targetDatabaseId}-page`; +const anchorPageId = `${prefix}-launch`; + +let dbModule: typeof import("../server/db/index.js"); +let listCandidates: typeof import("./list-content-relation-candidates.js").default; +let listHistory: typeof import("./list-content-relationship-history.js").default; +let propertyId: string; +let relationshipTypeId: string; +let relationshipTypeVersionId: string; +let configuredRevisionId: string; + +const candidateDocuments = [ + { id: `${prefix}-alpha`, title: "Alpha hidden", visible: false }, + { id: `${prefix}-bravo`, title: "Bravo visible", visible: true }, + { id: `${prefix}-charlie`, title: "Charlie hidden", visible: false }, + { id: `${prefix}-delta`, title: "Delta visible", visible: true }, + { id: `${prefix}-echo`, title: "Echo hidden", visible: false }, + { id: `${prefix}-foxtrot`, title: "Foxtrot visible", visible: true }, + { id: `${prefix}-ipek`, title: "İpek visible", visible: true }, +]; + +const asUser = (userEmail: string, run: () => Promise) => + runWithRequestContext({ userEmail }, run); + +beforeAll(async () => { + process.env.DATABASE_URL = `pglite:${databasePath}`; + dbModule = await import("../server/db/index.js"); + await (await import("../server/plugins/db.js")).default(undefined as never); + const configure = (await import("./configure-content-relation-property.js")) + .default; + listCandidates = (await import("./list-content-relation-candidates.js")) + .default; + listHistory = (await import("./list-content-relationship-history.js")) + .default; + + const filesDatabaseId = `${prefix}-files`; + await dbModule.getDb().insert(dbModule.schema.contentSpaces).values({ + id: spaceId, + name: "Relationship pagination", + kind: "personal", + ownerEmail: owner, + filesDatabaseId, + createdBy: owner, + }); + await dbModule + .getDb() + .insert(dbModule.schema.documents) + .values([ + { + id: `${filesDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Files", + }, + { + id: sourceDatabasePageId, + spaceId, + ownerEmail: owner, + title: "Campaign deliverables", + }, + { + id: targetDatabasePageId, + spaceId, + ownerEmail: owner, + title: "Marketing team", + }, + { + id: anchorPageId, + spaceId, + ownerEmail: owner, + title: "Launch article", + }, + ...candidateDocuments.map((candidate) => ({ + id: candidate.id, + spaceId, + ownerEmail: owner, + title: candidate.title, + })), + ]); + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabases) + .values([ + { + id: filesDatabaseId, + documentId: `${filesDatabaseId}-page`, + spaceId, + ownerEmail: owner, + title: "Files", + systemRole: "files", + blocksSeeded: 1, + }, + { + id: sourceDatabaseId, + documentId: sourceDatabasePageId, + spaceId, + ownerEmail: owner, + title: "Deliverables", + blocksSeeded: 1, + }, + { + id: targetDatabaseId, + documentId: targetDatabasePageId, + spaceId, + ownerEmail: owner, + title: "People", + blocksSeeded: 1, + }, + ]); + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabaseItems) + .values([ + { + id: `${prefix}-anchor-item`, + databaseId: sourceDatabaseId, + documentId: anchorPageId, + ownerEmail: owner, + }, + ...candidateDocuments.map((candidate, index) => ({ + id: `${prefix}-candidate-item-${index}`, + databaseId: targetDatabaseId, + documentId: candidate.id, + ownerEmail: owner, + position: index, + })), + ]); + const configured = await asUser(owner, () => + configure.run({ + ownerDatabaseId: sourceDatabaseId, + alias: "Contributors", + operationId: `${prefix}-configure`, + definition: { + kind: "new-local", + forwardLabel: "Contributes to", + inverseLabel: "Deliverables", + forwardCardinality: "many", + sourceDatabaseId, + targetDatabaseId, + }, + }), + ); + propertyId = configured.projection.propertyId; + relationshipTypeId = configured.relationshipType.id; + relationshipTypeVersionId = configured.relationshipTypeVersion.id; + configuredRevisionId = configured.revisionId; + + const sharedDocumentIds = [ + sourceDatabasePageId, + targetDatabasePageId, + anchorPageId, + ...candidateDocuments + .filter((candidate) => candidate.visible) + .map((candidate) => candidate.id), + ]; + await dbModule + .getDb() + .insert(dbModule.schema.documentShares) + .values( + sharedDocumentIds.map((resourceId, index) => ({ + id: `${prefix}-viewer-share-${index}`, + resourceId, + principalType: "user", + principalId: viewer, + role: "viewer", + createdBy: owner, + })), + ); +}); + +afterAll(() => { + delete process.env.DATABASE_URL; + rmSync(databasePath, { recursive: true, force: true }); +}); + +describe("relationship read pagination", () => { + it("paginates the authorized candidate set without counting hidden members", async () => { + const first = await asUser(viewer, () => + listCandidates.run({ + propertyId, + anchorPageId, + search: "", + contextPropertyIds: [], + limit: 2, + }), + ); + expect(first.items.map((candidate) => candidate.title)).toEqual([ + "Bravo visible", + "Delta visible", + ]); + expect(first.nextCursor).toBeTruthy(); + + const second = await asUser(viewer, () => + listCandidates.run({ + propertyId, + anchorPageId, + search: "", + contextPropertyIds: [], + limit: 2, + cursor: first.nextCursor!, + }), + ); + expect(second.items.map((candidate) => candidate.title)).toEqual([ + "Foxtrot visible", + "İpek visible", + ]); + expect(second.nextCursor).toBeNull(); + }); + + it("filters the authorized set in SQL before applying the page limit", async () => { + const result = await asUser(viewer, () => + listCandidates.run({ + propertyId, + anchorPageId, + search: "delta", + contextPropertyIds: [], + limit: 1, + }), + ); + expect(result.items.map((candidate) => candidate.title)).toEqual([ + "Delta visible", + ]); + expect(result.nextCursor).toBeNull(); + }); + + it("uses the database case mapping for both sides of non-ASCII search", async () => { + const result = await asUser(viewer, () => + listCandidates.run({ + propertyId, + anchorPageId, + search: "İPEK", + contextPropertyIds: [], + limit: 1, + }), + ); + expect(result.items.map((candidate) => candidate.title)).toEqual([ + "İpek visible", + ]); + }); + + it("preserves owner, public, organization, literal-search, order, and deletion semantics", async () => { + const { organizations, orgMembers } = + await import("@agent-native/core/org"); + await (dbModule.getDb() as any).$client.exec(` + CREATE TABLE organizations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_by TEXT NOT NULL, + created_at BIGINT NOT NULL, + allowed_domain TEXT, + a2a_secret TEXT, + workspace_url TEXT, + required_auth_provider TEXT, + identity_authority TEXT, + identity_id TEXT, + federation_roster_initialized_at BIGINT + ); + CREATE TABLE org_members ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + email TEXT NOT NULL, + role TEXT NOT NULL, + joined_at BIGINT NOT NULL, + federation_removal_pending_at BIGINT + ) + `); + const viewerOrgId = `${prefix}-viewer-org`; + const otherOrgId = `${prefix}-other-org`; + await dbModule + .getDb() + .insert(organizations) + .values([ + { + id: viewerOrgId, + name: "Viewer organization", + createdBy: owner, + createdAt: Date.now(), + }, + { + id: otherOrgId, + name: "Other organization", + createdBy: owner, + createdAt: Date.now(), + }, + ]); + await dbModule + .getDb() + .insert(orgMembers) + .values({ + id: `${prefix}-viewer-membership`, + orgId: viewerOrgId, + email: viewer, + role: "member", + joinedAt: Date.now(), + }); + const matrixDocuments = [ + { id: `${prefix}-public`, title: "Matrix public", visibility: "public" }, + { + id: `${prefix}-org-visible`, + title: "Matrix viewer org", + visibility: "org", + orgId: viewerOrgId, + }, + { + id: `${prefix}-org-hidden`, + title: "Matrix other org hidden", + visibility: "org", + orgId: otherOrgId, + }, + { id: `${prefix}-owner`, title: "Matrix owner-only" }, + { + id: `${prefix}-percent`, + title: "Matrix % literal", + visibility: "public", + }, + { + id: `${prefix}-underscore`, + title: "Matrix _ literal", + visibility: "public", + }, + { + id: `${prefix}-slash`, + title: "Matrix \\ literal", + visibility: "public", + }, + { id: `${prefix}-tie-a`, title: "Matrix tie", visibility: "public" }, + { id: `${prefix}-tie-b`, title: "Matrix tie", visibility: "public" }, + { + id: `${prefix}-trashed`, + title: "Matrix deleted trashed", + visibility: "public", + trashedAt: "2026-09-09T00:00:00.000Z", + }, + { + id: `${prefix}-permadeleted`, + title: "Matrix deleted permanent", + visibility: "public", + }, + ]; + await dbModule + .getDb() + .insert(dbModule.schema.documents) + .values( + matrixDocuments.map((document) => ({ + ...document, + spaceId, + ownerEmail: owner, + })), + ); + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabaseItems) + .values( + matrixDocuments.map((document, index) => ({ + id: `${document.id}-item`, + databaseId: targetDatabaseId, + documentId: document.id, + ownerEmail: owner, + position: 50 + index, + })), + ); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipEndpointStates) + .values({ + pageId: `${prefix}-permadeleted`, + ownerEmail: owner, + spaceId, + permanentlyDeletedAt: "2026-09-09T00:00:00.000Z", + }); + + const query = (userEmail: string, search: string, limit = 100) => + asUser(userEmail, () => + listCandidates.run({ + propertyId, + anchorPageId, + search, + contextPropertyIds: [], + limit, + }), + ); + await expect(query(viewer, "Matrix public")).resolves.toMatchObject({ + items: [{ pageId: `${prefix}-public` }], + }); + await expect(query(viewer, "Matrix viewer org")).resolves.toMatchObject({ + items: [{ pageId: `${prefix}-org-visible` }], + }); + await expect( + query(viewer, "Matrix other org hidden"), + ).resolves.toMatchObject({ items: [] }); + await expect(query(owner, "Matrix owner-only")).resolves.toMatchObject({ + items: [{ pageId: `${prefix}-owner` }], + }); + for (const [search, id] of [ + ["%", `${prefix}-percent`], + ["_", `${prefix}-underscore`], + ["\\", `${prefix}-slash`], + ]) { + const result = await query(viewer, search, 10); + expect(result.items.map((item) => item.pageId)).toEqual([id]); + } + const ties = await query(viewer, "Matrix tie", 10); + expect(ties.items.map((item) => item.pageId)).toEqual([ + `${prefix}-tie-a`, + `${prefix}-tie-b`, + ]); + const deleted = await query(viewer, "Matrix deleted", 10); + expect(deleted.items).toEqual([]); + }); + + it("keeps candidate query work constant as hidden membership grows", async () => { + const client = (dbModule.getDb() as any).$client; + const query = vi.spyOn(client, "query"); + await asUser(viewer, () => + listCandidates.run({ + propertyId, + anchorPageId, + search: "", + contextPropertyIds: [], + limit: 1, + }), + ); + const smallFixtureQueries = query.mock.calls.length; + expect(smallFixtureQueries).toBeGreaterThan(0); + + const hidden = Array.from({ length: 250 }, (_, index) => ({ + id: `${prefix}-bulk-hidden-${String(index).padStart(3, "0")}`, + title: `Bulk hidden ${index}`, + })); + await dbModule + .getDb() + .insert(dbModule.schema.documents) + .values( + hidden.map((document) => ({ + ...document, + spaceId, + ownerEmail: owner, + })), + ); + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabaseItems) + .values( + hidden.map((document, index) => ({ + id: `${document.id}-item`, + databaseId: targetDatabaseId, + documentId: document.id, + ownerEmail: owner, + position: 100 + index, + })), + ); + + query.mockClear(); + await asUser(viewer, () => + listCandidates.run({ + propertyId, + anchorPageId, + search: "", + contextPropertyIds: [], + limit: 1, + }), + ); + expect(query.mock.calls.length).toBe(smallFixtureQueries); + query.mockRestore(); + }); + + it("includes Property configuration in both Database Page histories", async () => { + const [sourceHistory, targetHistory] = await Promise.all([ + asUser(viewer, () => + listHistory.run({ pageId: sourceDatabasePageId, limit: 10 }), + ), + asUser(viewer, () => + listHistory.run({ pageId: targetDatabasePageId, limit: 10 }), + ), + ]); + expect(sourceHistory.items.map((item) => item.revisionId)).toContain( + configuredRevisionId, + ); + expect(targetHistory.items.map((item) => item.revisionId)).toContain( + configuredRevisionId, + ); + }); + + it("hides history when an owning Database Page is no longer accessible", async () => { + const shareId = `${prefix}-viewer-share-1`; + await dbModule + .getDb() + .delete(dbModule.schema.documentShares) + .where(eq(dbModule.schema.documentShares.id, shareId)); + try { + const history = await asUser(viewer, () => + listHistory.run({ pageId: sourceDatabasePageId, limit: 10 }), + ); + expect(history.items.map((item) => item.revisionId)).not.toContain( + configuredRevisionId, + ); + await expect( + asUser(viewer, () => + listHistory.run({ revisionId: configuredRevisionId }), + ), + ).rejects.toMatchObject({ errorCode: "NOT_ACCESSIBLE" }); + } finally { + await dbModule.getDb().insert(dbModule.schema.documentShares).values({ + id: shareId, + resourceId: targetDatabasePageId, + principalType: "user", + principalId: viewer, + role: "viewer", + createdBy: owner, + }); + } + }); + + it("fails closed instead of returning partial history before indexing finishes", async () => { + const revisionId = `${prefix}-unindexed-history`; + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipRevisions) + .values({ + id: revisionId, + ownerEmail: owner, + spaceId, + operationId: `${revisionId}-operation`, + operation: "mutate-relationships", + actorJson: JSON.stringify({ kind: "person", displayName: owner }), + origin: "frontend", + recoveryToken: `${revisionId}-recovery`, + }); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipEvents) + .values({ + id: `${revisionId}-event`, + ownerEmail: owner, + spaceId, + revisionId, + relationshipTypeId, + relationshipTypeVersionId, + kind: "relationship-added", + actorJson: JSON.stringify({ kind: "person", displayName: owner }), + origin: "frontend", + targetsJson: JSON.stringify({ + lineageId: `${revisionId}-lineage`, + sourcePageId: anchorPageId, + targetPageId: candidateDocuments[0]!.id, + }), + }); + try { + for (const input of [ + { pageId: sourceDatabasePageId }, + { relationshipTypeId }, + ]) { + await expect( + asUser(viewer, () => listHistory.run(input)), + ).rejects.toMatchObject({ errorCode: "UNAVAILABLE" }); + } + await expect( + asUser(viewer, () => listHistory.run({ revisionId })), + ).rejects.toMatchObject({ errorCode: "NOT_ACCESSIBLE" }); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipRevisionDocuments) + .values( + [ + anchorPageId, + candidateDocuments[0]!.id, + sourceDatabasePageId, + targetDatabasePageId, + ].map((documentId, index) => ({ + id: `${revisionId}-document-${index}`, + ownerEmail: owner, + spaceId, + revisionId, + documentId, + })), + ); + await expect( + asUser(viewer, () => listHistory.run({ revisionId })), + ).rejects.toMatchObject({ errorCode: "NOT_ACCESSIBLE" }); + await expect( + asUser(viewer, () => + listHistory.run({ revisionId: configuredRevisionId }), + ), + ).resolves.toMatchObject({ + items: [{ revisionId: configuredRevisionId }], + }); + } finally { + await dbModule + .getDb() + .delete(dbModule.schema.contentRelationshipRevisionDocuments) + .where( + eq( + dbModule.schema.contentRelationshipRevisionDocuments.revisionId, + revisionId, + ), + ); + await dbModule + .getDb() + .delete(dbModule.schema.contentRelationshipEvents) + .where( + eq(dbModule.schema.contentRelationshipEvents.revisionId, revisionId), + ); + await dbModule + .getDb() + .delete(dbModule.schema.contentRelationshipRevisions) + .where(eq(dbModule.schema.contentRelationshipRevisions.id, revisionId)); + } + }); + + it("reports a committed Revision with no Events as unavailable", async () => { + const revisionId = `${prefix}-empty-history`; + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipRevisions) + .values({ + id: revisionId, + ownerEmail: owner, + spaceId, + operationId: `${revisionId}-operation`, + operation: "mutate-relationships", + actorJson: JSON.stringify({ kind: "person", displayName: owner }), + origin: "frontend", + recoveryToken: `${revisionId}-recovery`, + }); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipRevisionDocuments) + .values( + [sourceDatabasePageId, targetDatabasePageId].map( + (documentId, index) => ({ + id: `${revisionId}-document-${index}`, + ownerEmail: owner, + spaceId, + revisionId, + documentId, + }), + ), + ); + try { + await expect( + asUser(viewer, () => listHistory.run({ pageId: sourceDatabasePageId })), + ).rejects.toMatchObject({ errorCode: "UNAVAILABLE" }); + await expect( + asUser(viewer, () => listHistory.run({ revisionId })), + ).rejects.toMatchObject({ errorCode: "UNAVAILABLE" }); + } finally { + await dbModule + .getDb() + .delete(dbModule.schema.contentRelationshipRevisionDocuments) + .where( + eq( + dbModule.schema.contentRelationshipRevisionDocuments.revisionId, + revisionId, + ), + ); + await dbModule + .getDb() + .delete(dbModule.schema.contentRelationshipRevisions) + .where(eq(dbModule.schema.contentRelationshipRevisions.id, revisionId)); + } + }); + + it("paginates authorized history without exposing hidden revisions in the cursor", async () => { + const createdAt = "2026-09-09T12:00:00.000Z"; + const revisions = [ + { id: `${prefix}-history-6`, target: candidateDocuments[1]!.id }, + { id: `${prefix}-history-5`, target: candidateDocuments[0]!.id }, + { id: `${prefix}-history-4`, target: candidateDocuments[3]!.id }, + { id: `${prefix}-history-3`, target: candidateDocuments[2]!.id }, + { id: `${prefix}-history-2`, target: candidateDocuments[5]!.id }, + { id: `${prefix}-history-1`, target: candidateDocuments[4]!.id }, + ]; + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipRevisions) + .values( + revisions.map(({ id }) => ({ + id, + ownerEmail: owner, + spaceId, + operationId: `${id}-operation`, + operation: "mutate-relationships", + actorJson: JSON.stringify({ + kind: "person", + displayName: owner, + email: owner, + }), + authorizingPrincipalJson: JSON.stringify({ + kind: "user", + email: owner, + orgId: null, + }), + origin: "frontend", + recoveryToken: `${id}-recovery`, + createdAt, + })), + ); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipEvents) + .values( + revisions.map(({ id, target }) => ({ + id: `${id}-event`, + ownerEmail: owner, + spaceId, + revisionId: id, + relationshipTypeId, + relationshipTypeVersionId, + kind: "relationship-added", + actorJson: JSON.stringify({ kind: "person", displayName: owner }), + authorizingPrincipalJson: JSON.stringify({ + kind: "user", + email: owner, + orgId: null, + }), + origin: "frontend", + targetsJson: JSON.stringify({ + lineageId: `${id}-lineage`, + sourcePageId: anchorPageId, + targetPageId: target, + }), + })), + ); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipRevisionDocuments) + .values( + revisions.flatMap(({ id, target }) => + [ + anchorPageId, + target, + sourceDatabasePageId, + targetDatabasePageId, + ].map((documentId, index) => ({ + id: `${id}-document-${index}`, + ownerEmail: owner, + spaceId, + revisionId: id, + documentId, + })), + ), + ); + + const first = await asUser(viewer, () => + listHistory.run({ pageId: anchorPageId, limit: 2 }), + ); + expect(first.items.map((item) => item.revisionId)).toEqual([ + `${prefix}-history-6`, + `${prefix}-history-4`, + ]); + expect(first.nextCursor).toBeTruthy(); + + const second = await asUser(viewer, () => + listHistory.run({ + pageId: anchorPageId, + limit: 2, + cursor: first.nextCursor!, + }), + ); + expect(second.items.map((item) => item.revisionId)).toEqual([ + `${prefix}-history-2`, + ]); + expect(second.nextCursor).toBeNull(); + }); + + it("never treats a colliding unresolved reference as a document grant", async () => { + const revisionId = `${prefix}-history-6`; + const tombstoneId = `${revisionId}-unresolved-collision`; + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipRevisionDocuments) + .values({ + id: tombstoneId, + ownerEmail: owner, + spaceId, + revisionId, + documentId: anchorPageId, + unresolved: 1, + }); + try { + const history = await asUser(viewer, () => + listHistory.run({ pageId: anchorPageId, limit: 10 }), + ); + expect(history.items.map((item) => item.revisionId)).not.toContain( + revisionId, + ); + await expect( + asUser(viewer, () => listHistory.run({ revisionId })), + ).rejects.toMatchObject({ errorCode: "NOT_ACCESSIBLE" }); + } finally { + await dbModule + .getDb() + .delete(dbModule.schema.contentRelationshipRevisionDocuments) + .where( + eq( + dbModule.schema.contentRelationshipRevisionDocuments.id, + tombstoneId, + ), + ); + } + }); + + it("keeps history detail-query work constant as hidden revisions grow", async () => { + const client = (dbModule.getDb() as any).$client; + const query = vi.spyOn(client, "query"); + await asUser(viewer, () => + listHistory.run({ pageId: anchorPageId, limit: 1 }), + ); + const smallFixtureQueries = query.mock.calls.length; + expect(smallFixtureQueries).toBeGreaterThan(0); + + const hiddenRevisions = Array.from({ length: 250 }, (_, index) => ({ + id: `${prefix}-bulk-history-${String(index).padStart(3, "0")}`, + target: candidateDocuments[0]!.id, + })); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipRevisions) + .values( + hiddenRevisions.map(({ id }) => ({ + id, + ownerEmail: owner, + spaceId, + operationId: `${id}-operation`, + operation: "mutate-relationships", + actorJson: JSON.stringify({ + kind: "person", + displayName: owner, + email: owner, + }), + authorizingPrincipalJson: JSON.stringify({ + kind: "user", + email: owner, + orgId: null, + }), + origin: "frontend", + recoveryToken: `${id}-recovery`, + createdAt: "2020-01-01T00:00:00.000Z", + })), + ); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipEvents) + .values( + hiddenRevisions.map(({ id, target }) => ({ + id: `${id}-event`, + ownerEmail: owner, + spaceId, + revisionId: id, + relationshipTypeId, + relationshipTypeVersionId, + kind: "relationship-added", + actorJson: JSON.stringify({ kind: "person", displayName: owner }), + authorizingPrincipalJson: JSON.stringify({ + kind: "user", + email: owner, + orgId: null, + }), + origin: "frontend", + targetsJson: JSON.stringify({ + lineageId: `${id}-lineage`, + sourcePageId: anchorPageId, + targetPageId: target, + }), + })), + ); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipRevisionDocuments) + .values( + hiddenRevisions.flatMap(({ id, target }) => + [ + anchorPageId, + target, + sourceDatabasePageId, + targetDatabasePageId, + ].map((documentId, index) => ({ + id: `${id}-document-${index}`, + ownerEmail: owner, + spaceId, + revisionId: id, + documentId, + })), + ), + ); + + query.mockClear(); + await asUser(viewer, () => + listHistory.run({ pageId: anchorPageId, limit: 1 }), + ); + expect(query.mock.calls.length).toBe(smallFixtureQueries); + query.mockRestore(); + }); + + it("keeps purged endpoint audit references private", async () => { + const revisionId = `${prefix}-history-6`; + await dbModule + .getDb() + .delete(dbModule.schema.documents) + .where(eq(dbModule.schema.documents.id, candidateDocuments[1]!.id)); + + const history = await asUser(viewer, () => + listHistory.run({ pageId: anchorPageId, limit: 10 }), + ); + expect(history.items.map((item) => item.revisionId)).not.toContain( + revisionId, + ); + await expect( + asUser(viewer, () => listHistory.run({ revisionId })), + ).rejects.toMatchObject({ errorCode: "NOT_ACCESSIBLE" }); + }); +}); diff --git a/templates/content/actions/relationship-revision-document-index.db.test.ts b/templates/content/actions/relationship-revision-document-index.db.test.ts new file mode 100644 index 00000000000..eac0e9e2a4c --- /dev/null +++ b/templates/content/actions/relationship-revision-document-index.db.test.ts @@ -0,0 +1,437 @@ +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runWithRequestContext } from "@agent-native/core/server"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const databasePath = join( + tmpdir(), + `relationship-revision-documents-${process.pid}-${Date.now()}.pglite`, +); +const owner = "relationship-revision-documents-owner@example.test"; +const spaceId = `relationship-revision-documents-${process.pid}-${Date.now()}`; + +let dbModule: typeof import("../server/db/index.js"); +let relationshipCore: typeof import("./_relationship-core.js"); +let listHistory: typeof import("./list-content-relationship-history.js").default; +let nextFixture = 0; + +type RelationshipDb = ReturnType< + (typeof import("../server/db/index.js"))["getDb"] +>; + +const asOwner = (run: () => Promise) => + runWithRequestContext({ userEmail: owner }, run); + +beforeAll(async () => { + process.env.DATABASE_URL = `pglite:${databasePath}`; + dbModule = await import("../server/db/index.js"); + await (await import("../server/plugins/db.js")).default(undefined as never); + relationshipCore = await import("./_relationship-core.js"); + listHistory = (await import("./list-content-relationship-history.js")) + .default; +}, 60_000); + +afterAll(() => { + delete process.env.DATABASE_URL; + rmSync(databasePath, { recursive: true, force: true }); +}); + +async function historicalRevision(options: { malformedSecondEvent?: boolean }) { + const prefix = `${spaceId}-${++nextFixture}`; + const typeId = `${prefix}-type`; + const versionId = `${prefix}-version`; + const sourcePageId = `${prefix}-source`; + const targetPageId = `${prefix}-target`; + const sourceDatabaseId = `${prefix}-missing-source-database`; + const targetDatabaseId = `${prefix}-missing-target-database`; + const lineageId = `${prefix}-lineage`; + const revisionId = `${prefix}-revision`; + await dbModule + .getDb() + .insert(dbModule.schema.documents) + .values([ + { + id: sourcePageId, + spaceId, + ownerEmail: owner, + title: "Source Page", + }, + { + id: targetPageId, + spaceId, + ownerEmail: owner, + title: "Target Page", + }, + ]); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipTypes) + .values({ + id: typeId, + ownerEmail: owner, + spaceId, + currentVersionId: versionId, + createdBy: owner, + }); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipTypeVersions) + .values({ + id: versionId, + ownerEmail: owner, + spaceId, + relationshipTypeId: typeId, + version: 1, + forwardLabel: "Related to", + inverseLabel: "Related from", + forwardCardinality: "many", + sourceDatabaseId, + targetDatabaseId, + createdBy: owner, + }); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipLineages) + .values({ + id: lineageId, + ownerEmail: owner, + spaceId, + relationshipTypeId: typeId, + sourcePageId, + targetPageId, + createdBy: owner, + }); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipRevisions) + .values({ + id: revisionId, + ownerEmail: owner, + spaceId, + operationId: `${prefix}-operation`, + operation: "mutate-relationships", + actorJson: JSON.stringify({ + kind: "person", + displayName: owner, + email: owner, + }), + authorizingPrincipalJson: JSON.stringify({ + kind: "user", + email: owner, + orgId: null, + }), + origin: "frontend", + recoveryToken: `${prefix}-recovery-token`, + diffJson: "{}", + }); + const event = (sequence: number) => ({ + id: `${prefix}-event-${sequence}`, + ownerEmail: owner, + spaceId, + revisionId, + sequence, + relationshipTypeId: typeId, + relationshipTypeVersionId: versionId, + kind: "relationship-added", + actorJson: JSON.stringify({ + kind: "person", + displayName: owner, + email: owner, + }), + authorizingPrincipalJson: JSON.stringify({ + kind: "user", + email: owner, + orgId: null, + }), + origin: "frontend", + routeJson: JSON.stringify({ + kind: "connections-forward", + sourcePageId, + }), + targetsJson: JSON.stringify({ + lineageId, + sourcePageId, + targetPageId, + }), + diffJson: JSON.stringify({ addedActivationIds: [`${prefix}-activation`] }), + }); + const events = [event(0)]; + if (options.malformedSecondEvent) { + events.push({ + ...event(1), + targetsJson: JSON.stringify({ lineageId, sourcePageId }), + }); + } + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipEvents) + .values(events); + return { + prefix, + revisionId, + sourcePageId, + targetPageId, + sourceDatabaseId, + targetDatabaseId, + }; +} + +describe("relationship Revision document indexing", () => { + it("processes only the requested batch before the release runner continues", async () => { + const first = await historicalRevision({}); + const second = await historicalRevision({}); + + await expect( + relationshipCore.backfillRelationshipRevisionDocuments(undefined, 1), + ).resolves.toEqual({ processed: 1 }); + const firstPass = await dbModule + .getDb() + .select({ + revisionId: + dbModule.schema.contentRelationshipRevisionDocuments.revisionId, + }) + .from(dbModule.schema.contentRelationshipRevisionDocuments); + expect(new Set(firstPass.map((row) => row.revisionId))).toEqual( + new Set([first.revisionId]), + ); + + await expect( + relationshipCore.backfillRelationshipRevisionDocuments(undefined, 1), + ).resolves.toEqual({ processed: 1 }); + const secondPass = await dbModule + .getDb() + .select({ + revisionId: + dbModule.schema.contentRelationshipRevisionDocuments.revisionId, + }) + .from(dbModule.schema.contentRelationshipRevisionDocuments); + expect(new Set(secondPass.map((row) => row.revisionId))).toEqual( + new Set([first.revisionId, second.revisionId]), + ); + await expect( + relationshipCore.backfillRelationshipRevisionDocuments(undefined, 1), + ).resolves.toEqual({ processed: 0 }); + }); + + it("backfills explicit inaccessible tombstones for deleted database metadata", async () => { + const seed = await historicalRevision({}); + + await relationshipCore.backfillRelationshipRevisionDocuments(); + + const references = await dbModule + .getDb() + .select({ + documentId: + dbModule.schema.contentRelationshipRevisionDocuments.documentId, + unresolved: + dbModule.schema.contentRelationshipRevisionDocuments.unresolved, + }) + .from(dbModule.schema.contentRelationshipRevisionDocuments) + .where( + eq( + dbModule.schema.contentRelationshipRevisionDocuments.revisionId, + seed.revisionId, + ), + ); + expect( + references.sort((left, right) => + left.documentId.localeCompare(right.documentId), + ), + ).toEqual( + [ + { documentId: seed.sourcePageId, unresolved: 0 }, + { documentId: seed.targetPageId, unresolved: 0 }, + { + documentId: `missing-database:${seed.sourceDatabaseId}`, + unresolved: 1, + }, + { + documentId: `missing-database:${seed.targetDatabaseId}`, + unresolved: 1, + }, + ].sort((left, right) => left.documentId.localeCompare(right.documentId)), + ); + await expect( + asOwner(() => listHistory.run({ pageId: seed.sourcePageId })), + ).resolves.toMatchObject({ items: [] }); + }); + + it("rolls back every reference when one historical Event is malformed", async () => { + const seed = await historicalRevision({ malformedSecondEvent: true }); + + await expect( + relationshipCore.backfillRelationshipRevisionDocuments(), + ).rejects.toMatchObject({ errorCode: "UNAVAILABLE" }); + const afterFailure = await dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipRevisionDocuments) + .where( + eq( + dbModule.schema.contentRelationshipRevisionDocuments.revisionId, + seed.revisionId, + ), + ); + expect(afterFailure).toHaveLength(0); + + await dbModule + .getDb() + .update(dbModule.schema.contentRelationshipEvents) + .set({ + targetsJson: JSON.stringify({ + lineageId: `${seed.prefix}-lineage`, + sourcePageId: seed.sourcePageId, + targetPageId: seed.targetPageId, + }), + }) + .where( + eq( + dbModule.schema.contentRelationshipEvents.id, + `${seed.prefix}-event-1`, + ), + ); + await relationshipCore.backfillRelationshipRevisionDocuments(); + const afterRepair = await dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipRevisionDocuments) + .where( + eq( + dbModule.schema.contentRelationshipRevisionDocuments.revisionId, + seed.revisionId, + ), + ); + expect(afterRepair.length).toBeGreaterThan(0); + }); + + it("keeps runtime Event writes strict when database metadata is missing", async () => { + const seed = await historicalRevision({}); + + await expect( + dbModule.getDb().transaction(async (rawTx) => { + const tx = rawTx as unknown as RelationshipDb; + const revision = await relationshipCore.createRelationshipRevision(tx, { + tenant: { ownerEmail: owner, orgId: null, spaceId }, + operationId: `${seed.prefix}-runtime-operation`, + operation: "mutate-relationships", + diff: {}, + context: { userEmail: owner }, + }); + await relationshipCore.appendRelationshipEvent(tx, revision, { + tenant: { ownerEmail: owner, orgId: null, spaceId }, + kind: "relationship-added", + relationshipTypeId: `${seed.prefix}-type`, + relationshipTypeVersionId: `${seed.prefix}-version`, + route: { + kind: "connections-forward", + sourcePageId: seed.sourcePageId, + }, + targets: { + lineageId: `${seed.prefix}-lineage`, + sourcePageId: seed.sourcePageId, + targetPageId: seed.targetPageId, + }, + diff: { addedActivationIds: [`${seed.prefix}-runtime-activation`] }, + }); + }), + ).rejects.toMatchObject({ errorCode: "UNAVAILABLE" }); + const revisions = await dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipRevisions) + .where( + eq( + dbModule.schema.contentRelationshipRevisions.operationId, + `${seed.prefix}-runtime-operation`, + ), + ); + expect(revisions).toHaveLength(0); + }); + + it("rolls back a Revision before writing an Event for another tenant", async () => { + const operationId = `${spaceId}-tenant-mismatch-${nextFixture++}`; + + await expect( + dbModule.getDb().transaction(async (rawTx) => { + const tx = rawTx as unknown as RelationshipDb; + const revision = await relationshipCore.createRelationshipRevision(tx, { + tenant: { ownerEmail: owner, orgId: null, spaceId }, + operationId, + operation: "mutate-relationships", + diff: {}, + context: { userEmail: owner }, + }); + await relationshipCore.appendRelationshipEvent(tx, revision, { + tenant: { + ownerEmail: owner, + orgId: null, + spaceId: `${spaceId}-other`, + }, + kind: "relationship-added", + route: {}, + targets: {}, + diff: {}, + }); + }), + ).rejects.toMatchObject({ errorCode: "UNAVAILABLE" }); + const revisions = await dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipRevisions) + .where( + eq( + dbModule.schema.contentRelationshipRevisions.operationId, + operationId, + ), + ); + expect(revisions).toHaveLength(0); + }); + + it("rolls back an Event whose lineage and declared endpoints disagree", async () => { + const seed = await historicalRevision({}); + const operationId = `${seed.prefix}-lineage-mismatch`; + + await expect( + dbModule.getDb().transaction(async (rawTx) => { + const tx = rawTx as unknown as RelationshipDb; + const revision = await relationshipCore.createRelationshipRevision(tx, { + tenant: { ownerEmail: owner, orgId: null, spaceId }, + operationId, + operation: "mutate-relationships", + diff: {}, + context: { userEmail: owner }, + }); + await relationshipCore.appendRelationshipEvent(tx, revision, { + tenant: { ownerEmail: owner, orgId: null, spaceId }, + kind: "relationship-added", + relationshipTypeId: `${seed.prefix}-type`, + relationshipTypeVersionId: `${seed.prefix}-version`, + route: { + kind: "connections-forward", + sourcePageId: seed.sourcePageId, + }, + targets: { + lineageId: `${seed.prefix}-lineage`, + sourcePageId: seed.sourcePageId, + targetPageId: `${seed.targetPageId}-wrong`, + }, + diff: { addedActivationIds: [`${seed.prefix}-activation`] }, + }); + }), + ).rejects.toMatchObject({ errorCode: "UNAVAILABLE" }); + const revisions = await dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipRevisions) + .where( + eq( + dbModule.schema.contentRelationshipRevisions.operationId, + operationId, + ), + ); + expect(revisions).toHaveLength(0); + }); +}); diff --git a/templates/content/scripts/migrate-production.ts b/templates/content/scripts/migrate-production.ts index daef451f16d..e63782e0716 100644 --- a/templates/content/scripts/migrate-production.ts +++ b/templates/content/scripts/migrate-production.ts @@ -1,6 +1,7 @@ import { closeDbExec, withMigrationRuntime } from "@agent-native/core/db"; import { runFrameworkReleaseMigrations } from "@agent-native/core/server"; +import { backfillRelationshipRevisionDocuments } from "../actions/_relationship-core.js"; import { runContentMigrations, runContentSourceMigrations, @@ -18,6 +19,12 @@ async function main(): Promise { await runFrameworkReleaseMigrations(null); await runContentMigrations(null); await runContentSourceMigrations(null); + while ( + (await backfillRelationshipRevisionDocuments(undefined, 100)).processed > + 0 + ) { + // Each call is bounded; the release owns traversal until no gaps remain. + } }); } diff --git a/templates/content/server/db/schema.ts b/templates/content/server/db/schema.ts index 2eb75662759..57ceb5d56e2 100644 --- a/templates/content/server/db/schema.ts +++ b/templates/content/server/db/schema.ts @@ -830,6 +830,31 @@ export const contentRelationshipEvents = table( ], ); +export const contentRelationshipRevisionDocuments = table( + "content_relationship_revision_documents", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + spaceId: text("space_id").notNull(), + revisionId: text("revision_id").notNull(), + documentId: text("document_id").notNull(), + unresolved: integer("unresolved").notNull().default(0), + createdAt: text("created_at").notNull().default(now()), + }, + (reference) => [ + uniqueIndex("content_relationship_revision_documents_unique").on( + reference.revisionId, + reference.documentId, + reference.unresolved, + ), + index("content_relationship_revision_documents_document_revision_idx").on( + reference.documentId, + reference.revisionId, + ), + ], +); + export const contentRelationshipReceipts = table( "content_relationship_receipts", { diff --git a/templates/content/server/plugins/db.ts b/templates/content/server/plugins/db.ts index 9b31ecc8ef9..d5cedc30ce9 100644 --- a/templates/content/server/plugins/db.ts +++ b/templates/content/server/plugins/db.ts @@ -1326,6 +1326,22 @@ export const runContentMigrations = runMigrations( name: "content-relationship-event-order", sql: `ALTER TABLE content_relationship_events ADD COLUMN IF NOT EXISTS sequence INTEGER NOT NULL DEFAULT 0`, }, + { + version: 92, + name: "content-relationship-revision-documents", + sql: `CREATE TABLE IF NOT EXISTS content_relationship_revision_documents ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + space_id TEXT NOT NULL, + revision_id TEXT NOT NULL, + document_id TEXT NOT NULL, + unresolved INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE UNIQUE INDEX IF NOT EXISTS content_relationship_revision_documents_unique ON content_relationship_revision_documents (revision_id, document_id, unresolved); + CREATE INDEX IF NOT EXISTS content_relationship_revision_documents_document_revision_idx ON content_relationship_revision_documents (document_id, revision_id)`, + }, ], { table: "content_migrations" }, ); From d7d562d4d08d4d5013229b22a9355922294610f4 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:26:02 -0400 Subject: [PATCH 3/5] fix: synchronize relationship changes and recover deleted-target slots --- .../content/actions/_relationship-core.ts | 55 +++- .../actions/_relationship-lifecycle.ts | 2 +- ...d-content-database-source-field.db.test.ts | 96 ++++--- .../actions/mutate-content-relationships.ts | 40 +-- .../actions/relationship-undo.db.test.ts | 251 +++++++++++++++++- .../remove-content-relation-property.ts | 2 +- .../undo-content-relationship-revision.ts | 51 ++-- .../DatabaseView.error-toasts.test.tsx | 6 +- .../app/hooks/content-action-refresh.ts | 41 ++- .../content/app/hooks/use-db-sync.spec.ts | 72 +++++ .../2026-09-08-typed-page-relationships.md | 1 + templates/content/parity/matrix.md | 63 ++--- templates/content/parity/matrix.ts | 41 +++ 13 files changed, 577 insertions(+), 144 deletions(-) diff --git a/templates/content/actions/_relationship-core.ts b/templates/content/actions/_relationship-core.ts index e17b11c9241..e146ab96b55 100644 --- a/templates/content/actions/_relationship-core.ts +++ b/templates/content/actions/_relationship-core.ts @@ -13,7 +13,7 @@ import { ROLE_RANK, type ShareRole, } from "@agent-native/core/sharing"; -import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; import type { @@ -1379,6 +1379,59 @@ export async function activeActivationIdsForLineages( return result; } +export async function liveRelationshipLineagesForSlot( + db: RelationshipDb, + relationshipTypeId: string, + sourcePageId: string, +): Promise<{ + lineages: Array; + active: Map; +}> { + const lineages = await db + .select() + .from(schema.contentRelationshipLineages) + .where( + and( + eq( + schema.contentRelationshipLineages.relationshipTypeId, + relationshipTypeId, + ), + eq(schema.contentRelationshipLineages.sourcePageId, sourcePageId), + ), + ); + const active = await activeActivationIdsForLineages( + db, + lineages.map((lineage) => lineage.id), + ); + const permanentlyDeletedTargets = lineages.length + ? await db + .select({ pageId: schema.contentRelationshipEndpointStates.pageId }) + .from(schema.contentRelationshipEndpointStates) + .where( + and( + inArray( + schema.contentRelationshipEndpointStates.pageId, + lineages.map((lineage) => lineage.targetPageId), + ), + isNotNull( + schema.contentRelationshipEndpointStates.permanentlyDeletedAt, + ), + ), + ) + : []; + const permanentlyDeletedTargetIds = new Set( + permanentlyDeletedTargets.map((row) => row.pageId), + ); + return { + lineages: lineages.filter( + (lineage) => + (active.get(lineage.id)?.length ?? 0) > 0 && + !permanentlyDeletedTargetIds.has(lineage.targetPageId), + ), + active, + }; +} + export async function retireRelationshipActivations( tx: RelationshipDb, args: { diff --git a/templates/content/actions/_relationship-lifecycle.ts b/templates/content/actions/_relationship-lifecycle.ts index 46967e9aa98..45193ee08b8 100644 --- a/templates/content/actions/_relationship-lifecycle.ts +++ b/templates/content/actions/_relationship-lifecycle.ts @@ -1,5 +1,5 @@ import type { ActionRunContext } from "@agent-native/core/action"; -import { and, eq, inArray, isNotNull, or } from "drizzle-orm"; +import { and, inArray, isNotNull, or } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; import { diff --git a/templates/content/actions/bind-content-database-source-field.db.test.ts b/templates/content/actions/bind-content-database-source-field.db.test.ts index 1cfa960f678..d57e59001f4 100644 --- a/templates/content/actions/bind-content-database-source-field.db.test.ts +++ b/templates/content/actions/bind-content-database-source-field.db.test.ts @@ -66,6 +66,14 @@ async function asOwner(fn: () => Promise): Promise { return runWithRequestContext({ userEmail: OWNER }, fn); } +function sortedItemValues( + values: readonly T[] | undefined, +): T[] { + return [...(values ?? [])].sort((left, right) => + left.itemId.localeCompare(right.itemId), + ); +} + /** * Seed a row-union database with two Builder sources. Source A has two rows * carrying a `data.cat` value (one of which is empty), plus a multi-value @@ -838,18 +846,20 @@ describe("add-content-database-source-field-property Builder refresh", () => { ); expect(readBuilderEntries).not.toHaveBeenCalled(); - expect(result.itemValues).toEqual([ - { - itemId: f.rows[0].itemId, - documentId: f.rows[0].documentId, - value: ["agent-native"], - }, - { - itemId: f.rows[1].itemId, - documentId: f.rows[1].documentId, - value: ["developer-experience"], - }, - ]); + expect(sortedItemValues(result.itemValues)).toEqual( + sortedItemValues([ + { + itemId: f.rows[0].itemId, + documentId: f.rows[0].documentId, + value: ["agent-native"], + }, + { + itemId: f.rows[1].itemId, + documentId: f.rows[1].documentId, + value: ["developer-experience"], + }, + ]), + ); expect(JSON.stringify(result)).not.toContain("_builder.bodyContent"); expect(JSON.stringify(result)).not.toContain("unrelated"); }); @@ -949,18 +959,20 @@ describe("add-content-database-source-field-property Builder refresh", () => { limit: 500, offset: 0, }); - expect(result.itemValues).toEqual([ - { - itemId: f.rows[0].itemId, - documentId: f.rows[0].documentId, - value: ["agent-native"], - }, - { - itemId: f.rows[1].itemId, - documentId: f.rows[1].documentId, - value: ["developer-experience"], - }, - ]); + expect(sortedItemValues(result.itemValues)).toEqual( + sortedItemValues([ + { + itemId: f.rows[0].itemId, + documentId: f.rows[0].documentId, + value: ["agent-native"], + }, + { + itemId: f.rows[1].itemId, + documentId: f.rows[1].documentId, + value: ["developer-experience"], + }, + ]), + ); const db = getDb(); const sourceRows = await db @@ -968,10 +980,24 @@ describe("add-content-database-source-field-property Builder refresh", () => { .from(schema.contentDatabaseSourceRows) .where(eq(schema.contentDatabaseSourceRows.sourceId, f.sourceId)); expect( - sourceRows.map((row) => { - return JSON.parse(row.sourceValuesJson)["data.topics"]; - }), - ).toEqual([["Agent-Native"], ["Developer Experience"]]); + sourceRows + .map((row) => ({ + sourceRowId: row.sourceRowId, + value: JSON.parse(row.sourceValuesJson)["data.topics"], + })) + .sort((left, right) => + left.sourceRowId.localeCompare(right.sourceRowId), + ), + ).toEqual( + f.rows + .map((row, index) => ({ + sourceRowId: row.entryId, + value: index === 0 ? ["Agent-Native"] : ["Developer Experience"], + })) + .sort((left, right) => + left.sourceRowId.localeCompare(right.sourceRowId), + ), + ); const properties = await db .select() .from(schema.documentPropertyDefinitions) @@ -1313,12 +1339,14 @@ describe("add-content-database-source-field-property Builder refresh", () => { { id: "current-choice", name: "Current Choice", color: "blue" }, { id: "second-choice", name: "Second Choice", color: "green" }, ]); - expect(result.itemValues).toEqual( - f.rows.map((row) => ({ - itemId: row.itemId, - documentId: row.documentId, - value: "current-choice", - })), + expect(sortedItemValues(result.itemValues)).toEqual( + sortedItemValues( + f.rows.map((row) => ({ + itemId: row.itemId, + documentId: row.documentId, + value: "current-choice", + })), + ), ); const [property] = await db diff --git a/templates/content/actions/mutate-content-relationships.ts b/templates/content/actions/mutate-content-relationships.ts index cf2ea29cbea..8154754922c 100644 --- a/templates/content/actions/mutate-content-relationships.ts +++ b/templates/content/actions/mutate-content-relationships.ts @@ -1,5 +1,5 @@ import { defineAction, type ActionRunContext } from "@agent-native/core/action"; -import { and, eq, inArray, isNotNull, sql } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; import { @@ -24,6 +24,7 @@ import { insertRelationshipReceipt, loadRelationshipDatabase, loadRelationshipTypeBundle, + liveRelationshipLineagesForSlot, lockRelationshipCardinalitySlots, lockRelationshipOperation, lockRelationshipLineages, @@ -319,41 +320,8 @@ async function liveLineagesForSlot( typeId: string, sourcePageId: string, ) { - const lineages = await tx - .select() - .from(schema.contentRelationshipLineages) - .where( - and( - eq(schema.contentRelationshipLineages.relationshipTypeId, typeId), - eq(schema.contentRelationshipLineages.sourcePageId, sourcePageId), - ), - ); - const activeByLineage = await activeActivationIdsForLineages( - tx, - lineages.map((lineage) => lineage.id), - ); - const deletedTargets = lineages.length - ? await tx - .select({ pageId: schema.contentRelationshipEndpointStates.pageId }) - .from(schema.contentRelationshipEndpointStates) - .where( - and( - inArray( - schema.contentRelationshipEndpointStates.pageId, - lineages.map((lineage) => lineage.targetPageId), - ), - isNotNull( - schema.contentRelationshipEndpointStates.permanentlyDeletedAt, - ), - ), - ) - : []; - const deletedTargetIds = new Set(deletedTargets.map((row) => row.pageId)); - return lineages.filter( - (lineage) => - (activeByLineage.get(lineage.id)?.length ?? 0) > 0 && - !deletedTargetIds.has(lineage.targetPageId), - ); + return (await liveRelationshipLineagesForSlot(tx, typeId, sourcePageId)) + .lineages; } async function getOrCreateLineage( diff --git a/templates/content/actions/relationship-undo.db.test.ts b/templates/content/actions/relationship-undo.db.test.ts index 359e74532e9..5e46d052d53 100644 --- a/templates/content/actions/relationship-undo.db.test.ts +++ b/templates/content/actions/relationship-undo.db.test.ts @@ -16,6 +16,7 @@ const spaceId = `relationship-undo-${process.pid}-${Date.now()}`; let dbModule: typeof import("../server/db/index.js"); let configure: typeof import("./configure-content-relation-property.js").default; +let listCandidates: typeof import("./list-content-relation-candidates.js").default; let listRelationships: typeof import("./list-content-relationships.js").default; let listHistory: typeof import("./list-content-relationship-history.js").default; let mutate: typeof import("./mutate-content-relationships.js").default; @@ -35,6 +36,8 @@ beforeAll(async () => { await (await import("../server/plugins/db.js")).default(undefined as never); configure = (await import("./configure-content-relation-property.js")) .default; + listCandidates = (await import("./list-content-relation-candidates.js")) + .default; listRelationships = (await import("./list-content-relationships.js")).default; listHistory = (await import("./list-content-relationship-history.js")) .default; @@ -220,10 +223,10 @@ async function add(seed: Fixture, operationId: string, targetPageId: string) { return asOwner(() => mutate.run(addInput(seed, operationId, targetPageId))); } -async function outgoing(seed: Fixture) { +async function outgoing(seed: Fixture, pageId = seed.sourcePageId) { return asOwner(() => listRelationships.run({ - pageId: seed.sourcePageId, + pageId, relationshipTypeId: seed.typeId, direction: "outgoing", }), @@ -466,6 +469,164 @@ describe("typed relationship Undo", () => { ).rejects.toMatchObject({ errorCode: "STALE_RECOVERY" }); }); + it("undoes atomic replacements when a source retains a permanently deleted target", async () => { + const seed = await fixture("one"); + const socialPageId = `${seed.prefix}-social`; + const deletedTargetPageId = `${seed.prefix}-deleted-target`; + await dbModule + .getDb() + .insert(dbModule.schema.documents) + .values([ + { + id: socialPageId, + spaceId, + ownerEmail: owner, + title: "Social post", + }, + { + id: deletedTargetPageId, + spaceId, + ownerEmail: owner, + title: "Deleted teammate", + }, + ]); + await dbModule + .getDb() + .insert(dbModule.schema.contentDatabaseItems) + .values([ + { + id: `${socialPageId}-item`, + databaseId: seed.sourceDatabaseId, + documentId: socialPageId, + ownerEmail: owner, + }, + { + id: `${deletedTargetPageId}-item`, + databaseId: seed.targetDatabaseId, + documentId: deletedTargetPageId, + ownerEmail: owner, + }, + ]); + const hidden = await asOwner(() => + mutate.run({ + operationId: `${seed.prefix}-add-hidden-social-target`, + changes: [ + { + kind: "add", + typeId: seed.typeId, + typeVersionId: seed.typeVersionId, + sourcePageId: socialPageId, + targetPageId: deletedTargetPageId, + route: { + kind: "forward-property", + propertyId: seed.propertyId, + sourcePageId: socialPageId, + }, + }, + ], + }), + ); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipEndpointStates) + .values({ + pageId: deletedTargetPageId, + ownerEmail: owner, + spaceId, + permanentlyDeletedAt: new Date().toISOString(), + }); + + const launchInitial = await add( + seed, + `${seed.prefix}-add-launch-target`, + seed.targetPageIds[0], + ); + const launchObserved = (await outgoing(seed)).items[0]!; + const socialCandidates = await asOwner(() => + listCandidates.run({ + propertyId: seed.propertyId, + anchorPageId: socialPageId, + contextPropertyIds: [], + limit: 100, + }), + ); + expect((await outgoing(seed, socialPageId)).items).toEqual([]); + + const replacement = await asOwner(() => + mutate.run({ + operationId: `${seed.prefix}-replace-launch-and-social`, + changes: [ + { + kind: "replace", + typeId: seed.typeId, + typeVersionId: seed.typeVersionId, + sourcePageId: seed.sourcePageId, + targetPageId: seed.targetPageIds[1], + observedSlotToken: launchObserved.slotObservationToken!, + route: launchObserved.routes[0]!, + }, + { + kind: "replace", + typeId: seed.typeId, + typeVersionId: seed.typeVersionId, + sourcePageId: socialPageId, + targetPageId: seed.targetPageIds[1], + observedSlotToken: socialCandidates.slotObservationToken!, + route: { + kind: "forward-property", + propertyId: seed.propertyId, + sourcePageId: socialPageId, + }, + }, + ], + }), + ); + const history = await asOwner(() => + listHistory.run({ revisionId: replacement.revisionId }), + ); + const restored = await asOwner(() => + undo.run({ + revisionId: replacement.revisionId, + recoveryToken: history.items[0]!.recovery.recoveryToken!, + operationId: `${seed.prefix}-undo-launch-and-social`, + routes: [], + }), + ); + const restoredHistory = await asOwner(() => + listHistory.run({ revisionId: restored.revisionId }), + ); + + expect(restored.results).toHaveLength(2); + expect( + restoredHistory.items[0]!.changes.find( + (change) => change.source.pageId === socialPageId, + ), + ).toEqual({ + eventId: expect.any(String), + kind: "removed", + relationshipTypeId: seed.typeId, + relationshipLabel: "Contributes to", + source: { pageId: socialPageId, title: "Social post" }, + target: { pageId: seed.targetPageIds[1], title: "Jo" }, + }); + expect((await outgoing(seed)).items[0]!.edgeId).toBe( + launchInitial.results[0]!.edgeId, + ); + expect((await outgoing(seed, socialPageId)).items).toEqual([]); + const hiddenActivationId = hidden.results[0]!.activationIds[0]!; + const hiddenRetirements = await dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipActivationRetirements) + .where( + eq( + dbModule.schema.contentRelationshipActivationRetirements.activationId, + hiddenActivationId, + ), + ); + expect(hiddenRetirements).toEqual([]); + }); + it("restores the same Property identity and selected relationship", async () => { const seed = await fixture(); const added = await add(seed, `${seed.prefix}-add`, seed.targetPageIds[0]); @@ -507,6 +668,92 @@ describe("typed relationship Undo", () => { ); }); + it("restores a max-one Property beside a permanently deleted target", async () => { + const seed = await fixture("one"); + const previous = await add( + seed, + `${seed.prefix}-add-previous-target`, + seed.targetPageIds[1], + ); + const observed = (await outgoing(seed)).items[0]!; + const visible = await asOwner(() => + mutate.run({ + operationId: `${seed.prefix}-replace-with-visible-target`, + changes: [ + { + kind: "replace", + typeId: seed.typeId, + typeVersionId: seed.typeVersionId, + sourcePageId: seed.sourcePageId, + targetPageId: seed.targetPageIds[0], + observedSlotToken: observed.slotObservationToken!, + route: observed.routes[0]!, + }, + ], + }), + ); + const prepared = await asOwner(() => + prepareRemoval.run({ + selection: { kind: "property", propertyId: seed.propertyId }, + }), + ); + const removed = await asOwner(() => + removeProperty.run({ + propertyId: seed.propertyId, + relationshipMode: { + kind: "remove-selected", + selectionReceipt: prepared.selectionReceipt, + }, + operationId: `${seed.prefix}-remove-property`, + }), + ); + const hiddenActivationId = `${seed.prefix}-hidden-activation`; + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipActivations) + .values({ + id: hiddenActivationId, + ownerEmail: owner, + spaceId, + lineageId: previous.results[0]!.lineageId, + addedEventId: `${seed.prefix}-hidden-legacy-event`, + createdBy: owner, + }); + await dbModule + .getDb() + .insert(dbModule.schema.contentRelationshipEndpointStates) + .values({ + pageId: seed.targetPageIds[1], + ownerEmail: owner, + spaceId, + permanentlyDeletedAt: new Date().toISOString(), + }); + + await asOwner(() => + undo.run({ + revisionId: removed.revisionId, + recoveryToken: removed.undo.recoveryToken, + operationId: `${seed.prefix}-undo-property`, + routes: [], + }), + ); + + expect((await outgoing(seed)).items[0]!.edgeId).toBe( + visible.results[0]!.edgeId, + ); + const hiddenRetirements = await dbModule + .getDb() + .select() + .from(dbModule.schema.contentRelationshipActivationRetirements) + .where( + eq( + dbModule.schema.contentRelationshipActivationRetirements.activationId, + hiddenActivationId, + ), + ); + expect(hiddenRetirements).toEqual([]); + }); + it("preserves relation column presentation and later unrelated view edits", async () => { const seed = await fixture(); const relationPresentation = { diff --git a/templates/content/actions/remove-content-relation-property.ts b/templates/content/actions/remove-content-relation-property.ts index da813da6546..ed058cbcc83 100644 --- a/templates/content/actions/remove-content-relation-property.ts +++ b/templates/content/actions/remove-content-relation-property.ts @@ -1,5 +1,5 @@ import { defineAction, type ActionRunContext } from "@agent-native/core/action"; -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; import { diff --git a/templates/content/actions/undo-content-relationship-revision.ts b/templates/content/actions/undo-content-relationship-revision.ts index 513a7c64d1d..47bcee75397 100644 --- a/templates/content/actions/undo-content-relationship-revision.ts +++ b/templates/content/actions/undo-content-relationship-revision.ts @@ -3,7 +3,7 @@ import { isActionContractError, type ActionRunContext, } from "@agent-native/core/action"; -import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -27,6 +27,7 @@ import { insertRelationshipReceipt, loadRelationshipDatabase, loadRelationshipTypeBundle, + liveRelationshipLineagesForSlot, lockRelationshipCardinalitySlots, lockRelationshipLineages, lockRelationshipOperation, @@ -382,32 +383,6 @@ async function assertPropertyRemovalAccess( } } -async function activeLineagesForSlot( - db: RelationshipDb, - typeId: string, - sourcePageId: string, -) { - const lineages = await db - .select() - .from(schema.contentRelationshipLineages) - .where( - and( - eq(schema.contentRelationshipLineages.relationshipTypeId, typeId), - eq(schema.contentRelationshipLineages.sourcePageId, sourcePageId), - ), - ); - const active = await activeActivationIdsForLineages( - db, - lineages.map((lineage) => lineage.id), - ); - return { - lineages: lineages.filter( - (lineage) => (active.get(lineage.id)?.length ?? 0) > 0, - ), - active, - }; -} - async function addRecoveryActivation( tx: RelationshipDb, args: { @@ -815,7 +790,7 @@ async function undoContentRelationshipRevision( if (plan.originalKind === "remove") { if (plan.bundle.version.forwardCardinality === "one") { - const slot = await activeLineagesForSlot( + const slot = await liveRelationshipLineagesForSlot( tx, plan.bundle.type.id, plan.lineage.sourcePageId, @@ -892,7 +867,7 @@ async function undoContentRelationshipRevision( { statusCode: 503 }, ); } - const slot = await activeLineagesForSlot( + const slot = await liveRelationshipLineagesForSlot( tx, plan.bundle.type.id, plan.lineage.sourcePageId, @@ -934,7 +909,9 @@ async function undoContentRelationshipRevision( await appendRelationshipEvent(tx, revision, { tenant, eventId, - kind: "relationship-replacement-undone", + kind: restored + ? "relationship-replacement-undone" + : "relationship-add-undone", relationshipTypeId: plan.bundle.type.id, relationshipTypeVersionId: plan.bundle.version.id, route: restored @@ -944,12 +921,14 @@ async function undoContentRelationshipRevision( lineageId: restored?.id ?? plan.lineage.id, sourcePageId: plan.lineage.sourcePageId, targetPageId: restored?.targetPageId ?? plan.lineage.targetPageId, - displacedLineageIds: [plan.lineage.id], - }, - diff: { - addedActivationIds: activationIds, - retiredActivationIds: retiredIds, + ...(restored ? { displacedLineageIds: [plan.lineage.id] } : {}), }, + diff: restored + ? { + addedActivationIds: activationIds, + retiredActivationIds: retiredIds, + } + : { retiredActivationIds: retiredIds }, }); await updateSlot(tx, { typeId: plan.bundle.type.id, @@ -1066,7 +1045,7 @@ async function undoContentRelationshipRevision( db: tx, }); if (bundle.version.forwardCardinality === "one") { - const slot = await activeLineagesForSlot( + const slot = await liveRelationshipLineagesForSlot( tx, bundle.type.id, entry.sourcePageId, diff --git a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx index ad73887ac64..12d6c314c4a 100644 --- a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx @@ -144,6 +144,10 @@ vi.mock("@agent-native/core/client/settings", () => ({ vi.mock("@/hooks/use-content-database", () => ({ isContentDatabaseUnavailable: () => false, + useContentDatabases: () => ({ + data: { databases: [] }, + isLoading: false, + }), useContentDatabase: ( documentId: string, limit: number, @@ -513,7 +517,7 @@ describe("DatabaseView UI regressions", () => { expect(updateViewMutation.mutateAsync).not.toHaveBeenCalled(); await act(async () => { - navigateRoute?.("/page/document-1?databaseViewId=numbers", { + await navigateRoute?.("/page/document-1?databaseViewId=numbers", { replace: true, }); await Promise.resolve(); diff --git a/templates/content/app/hooks/content-action-refresh.ts b/templates/content/app/hooks/content-action-refresh.ts index ce653ff83d3..cbfdea90928 100644 --- a/templates/content/app/hooks/content-action-refresh.ts +++ b/templates/content/app/hooks/content-action-refresh.ts @@ -15,6 +15,26 @@ const COMMENT_MUTATIONS = new Set([ "update-comment", ]); +const RELATIONSHIP_MUTATIONS = new Set([ + "configure-content-relation-property", + "mutate-content-relationships", + "remove-content-relation-property", + "undo-content-relationship-revision", +]); + +const RELATIONSHIP_PROPERTY_MUTATIONS = new Set([ + "configure-content-relation-property", + "remove-content-relation-property", + "undo-content-relationship-revision", +]); + +const RELATIONSHIP_QUERIES = new Set([ + "list-content-relation-candidates", + "list-content-relationship-history", + "list-content-relationship-types", + "list-content-relationships", +]); + const DOCUMENT_MUTATIONS = new Set([ "create-and-link-notion-page", "delete-document", @@ -75,6 +95,7 @@ const DATABASE_RESULT_MUTATIONS = new Set([ "update-content-database-view", "update-document", "upsert-database-item-by-key", + ...RELATIONSHIP_MUTATIONS, ]); const DATABASE_PRESENTATION_MUTATIONS = new Set([ @@ -115,6 +136,14 @@ function isDatabaseQuery(query: ActionQuery): boolean { return true; } +function isRelationshipQuery(query: ActionQuery): boolean { + return ( + query.queryKey[0] === "action" && + typeof query.queryKey[1] === "string" && + RELATIONSHIP_QUERIES.has(query.queryKey[1]) + ); +} + function queryTargetsDatabase(query: ActionQuery, documentId: string): boolean { if (!isDatabaseQuery(query)) return false; const args = query.queryKey[2]; @@ -159,6 +188,14 @@ export function contentActionInvalidatePredicate( if (documentId === undefined) { return false; } + if (isRelationshipQuery(query)) { + return events.some( + (event) => + event.source === "action" && + typeof event.key === "string" && + RELATIONSHIP_MUTATIONS.has(event.key), + ); + } if ( typeof targetId === "string" && queryTargetsDocument(query, targetId) && @@ -170,7 +207,9 @@ export function contentActionInvalidatePredicate( (event) => event.source === "action" && typeof event.key === "string" && - CONTENT_MUTATIONS.has(event.key), + (CONTENT_MUTATIONS.has(event.key) || + (query.queryKey[1] === "list-document-properties" && + RELATIONSHIP_PROPERTY_MUTATIONS.has(event.key))), ); } if (queryTargetsDatabase(query, documentId)) { diff --git a/templates/content/app/hooks/use-db-sync.spec.ts b/templates/content/app/hooks/use-db-sync.spec.ts index fd2861992de..88a388875e5 100644 --- a/templates/content/app/hooks/use-db-sync.spec.ts +++ b/templates/content/app/hooks/use-db-sync.spec.ts @@ -131,6 +131,78 @@ describe("contentActionInvalidatePredicate", () => { ).toBe(false); }); + it.each([ + "configure-content-relation-property", + "mutate-content-relationships", + "remove-content-relation-property", + "undo-content-relationship-revision", + ])( + "refreshes relationship-backed table results after agent action %s", + (key) => { + const predicate = contentActionInvalidatePredicate("/page/database-page"); + const event = [{ source: "action", key }]; + + expect( + predicate( + { + queryKey: [ + "action", + "get-content-database", + { documentId: "database-page", limit: 100 }, + ], + }, + event, + ), + ).toBe(true); + expect( + predicate( + { + queryKey: [ + "action", + "query-content-database-items", + { documentId: "database-page", tableQuery: {} }, + ], + }, + event, + ), + ).toBe(true); + expect( + predicate( + { + queryKey: [ + "action", + "list-content-relationship-history", + { pageId: "database-page" }, + ], + }, + event, + ), + ).toBe(true); + }, + ); + + it.each([ + "configure-content-relation-property", + "remove-content-relation-property", + "undo-content-relationship-revision", + ])("refreshes relation Property definitions after agent action %s", (key) => { + const predicate = contentActionInvalidatePredicate("/page/database-page"); + + expect( + predicate( + { + queryKey: [ + "action", + "list-document-properties", + { documentId: "database-page", databaseId: "database" }, + ], + isActive: () => true, + }, + [{ source: "action", key }], + ), + ).toBe(true); + }); + it("refreshes an active inline database mounted on another host page", () => { const predicate = contentActionInvalidatePredicate("/page/host-document"); const inlineDatabaseQuery = { diff --git a/templates/content/changelog/2026-09-08-typed-page-relationships.md b/templates/content/changelog/2026-09-08-typed-page-relationships.md index 2d52b8e3829..eed9075e2c2 100644 --- a/templates/content/changelog/2026-09-08-typed-page-relationships.md +++ b/templates/content/changelog/2026-09-08-typed-page-relationships.md @@ -2,4 +2,5 @@ type: added date: 2026-09-08 --- + Connect pages with Relation Properties, manage assignments from either direction, and recover relationship changes from history. diff --git a/templates/content/parity/matrix.md b/templates/content/parity/matrix.md index e0fdfb92dc1..9822fbef60e 100644 --- a/templates/content/parity/matrix.md +++ b/templates/content/parity/matrix.md @@ -2,34 +2,35 @@ This generated matrix tracks whether high-value Content UI operations use the same action surface agents can call, or have an explicit exception. Edit `matrix.ts`, then regenerate this file. -| ID | Surface | User-visible action | Status | Actions | UI entrypoints | Durable effect | Exception / gap | Reliability risk | Spine priority | Test coverage | Coverage refs | Eval scenarios | Follow-up | -| -------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | -------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------ | -| comments.threads | comments | List, add, reply, resolve, reopen, and delete comment threads | action-backed | `add-comment`, `delete-comment`, `list-comments`, `update-comment` | `app/components/editor/CommentsSidebar.tsx`, `app/hooks/use-comments.ts` | Comment threads, replies, anchors, mentions, resolution state, and deletion are stored through comment actions. | - | - | P0 | seeded | - | - | - | -| database.form-submissions | database | Submit public database forms as new rows | action-backed | `submit-content-database-form` | `app/components/editor/database/FormView.tsx` | A validated form submission atomically creates a database row document and its editable property values. | - | - | P0 | covered | `actions/submit-content-database-form.db.test.ts` | - | - | -| database.lifecycle-and-trash | database | Create, soft-delete, restore, list, and inspect content databases | action-backed | `create-content-database`, `create-inline-content-database`, `delete-content-database`, `describe-content-database`, `get-content-database`, `list-content-databases`, `list-trashed-content-databases`, `restore-content-database` | `app/components/editor/SlashCommandMenu.tsx`, `app/hooks/use-content-database.ts`, `app/hooks/use-documents.ts` | Database pages and database records are created, read, soft-deleted, restored, and listed. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/list-content-databases.db.test.ts`, `server/plugins/agent-chat.spec.ts`, `../../packages/core/src/server/agent-chat/content-a2a-capabilities.spec.ts` | `database-source-scope` | - | -| database.private-preview-drafts | database | Persist and reconcile a user's private database-page preview draft | action-backed | `get-preview-document-draft`, `update-preview-document-draft` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-documents.ts` | A user's private preview draft is read, saved, conflict-checked, or deleted without changing the shared database page until the normal save flow applies it. | These per-user editor-state actions are intentionally hidden from agent tools because preview drafts are a private UI recovery mechanism. | - | P1 | covered | `actions/preview-document-draft.db.test.ts` | - | - | -| database.properties-and-view-config | database | Configure properties, values, ordering, and saved views | action-backed | `configure-document-property`, `delete-document-property`, `duplicate-document-property`, `get-content-database-personal-view`, `list-document-properties`, `reorder-document-property`, `set-document-property`, `update-content-database-personal-view`, `update-content-database-view` | `app/components/editor/DocumentProperties.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-document-properties.ts` | Property schemas, property values, property order, filters, sorts, grouping, hidden columns, view type, and view settings are stored. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | -| database.rows | database | Add, duplicate, move, open, edit, and remove database rows | action-backed | `add-database-item`, `update-database-item`, `upsert-database-item-by-key`, `list-content-database-blocks`, `mutate-content-database-block`, `remove-database-items`, `duplicate-database-items`, `duplicate-database-item`, `update-database-items`, `migrate-content-database-rows`, `manage-content-database-migration`, `move-database-item`, `set-document-property` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx` | Database row memberships and ordering are created, duplicated, moved, edited, and removed without deleting the backing page; bounded migrations atomically update row bodies and properties through the same canonical data model. | - | - | P0 | covered | `actions/database-row-batch-actions.db.test.ts`, `actions/upsert-database-item-by-key.db.test.ts`, `actions/migrate-content-database-rows.db.test.ts`, `actions/content-database-block-actions.db.test.ts`, `parity/__tests__/database-row-batch-reliability.test.ts` | `database-bulk-row-reliability` | - | -| database.table-query-page | database | Query one constrained page while retaining database metadata | action-backed | `query-content-database-items` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | - | This UI-only bounded projection is intentionally hidden with agentTool: false; agents use get-content-database for the complete database contract. | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `app/hooks/use-content-database.test.ts` | - | - | -| editor.agent-assist-prompts | editor | Ask AI from slash generation or comment context | client-assist | - | `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/CommentsSidebar.tsx` | No direct durable mutation; the prompt asks the agent to use document actions when it decides to write. | - | - | P1 | none | - | - | - | -| editor.blocks-field-word-count | editor | Inspect per-field word counts | action-backed | `get-blocks-field-word-count` | `app/components/editor/DocumentInfoPanel.tsx` | Authorized Blocks-field word counts read the current field without combining sibling fields. | - | - | P1 | covered | `actions/get-blocks-field-word-count.test.ts`, `app/components/editor/DocumentInfoPanel.test.ts` | - | - | -| editor.client-formatting-and-insertions | editor | Rich text formatting, selection state, slash block insertion, and copy actions | client-only-ephemeral | - | `app/components/editor/BubbleToolbar.tsx`, `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/DocumentToolbar.tsx` | - | - | - | P1 | none | - | - | - | -| editor.document-body-and-title | editor | Edit document title, body, icon, image alt text, and precise text | action-backed | `edit-document`, `pull-document`, `set-image-alt-text`, `transcribe-media`, `update-document` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/extensions/ImageBlock.tsx`, `app/components/editor/SlashCommandMenu.tsx` | Document content, title, icon, image metadata, and text replacements are saved to the same document source. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | -| local-files.components-workspace | local-files | Register, list, and write local MDX component workspaces | host-only | `list-local-component-files`, `register-local-component-workspace`, `write-local-component-file` | `app/routes/_app.local-files.tsx`, `actions/register-local-component-workspace.ts`, `actions/list-local-component-files.ts`, `actions/write-local-component-file.ts` | Trusted local component workspace registration and component file reads/writes support local MDX previews. | Workspace registration depends on a trusted Desktop folder path and is intentionally hidden with agentTool: false. | - | P1 | seeded | - | - | Local folder exception/docs PR | -| local-files.host-folder-handles | local-files | Choose, persist, remove, and write trusted local folder handles | host-only | - | `app/routes/_app.local-files.tsx` | Host directory handles and browser/Desktop write permissions are managed outside SQL action state. | Mounted local folders require browser/Desktop host handles that agents cannot safely or portably hold as normal tools. | - | P0 | none | - | - | Local folder exception/docs PR | -| local-files.import-export-mounted-folder | local-files | Import, check, export, push, and remove local folder source files | action-backed | `connect-local-folder-source`, `disconnect-local-folder-source`, `export-content-source`, `import-content-source`, `remove-local-file-source`, `resolve-local-folder-conflict`, `sync-local-folder-source`, `sync-manifest-local-folder-source` | `app/routes/_app.local-files.tsx`, `actions/import-content-source.ts`, `actions/export-content-source.ts` | Local Markdown/MDX source files are imported into Content documents, editable Content documents are exported back to source-friendly files, and imported source entries can be removed without deleting files on disk. | - | - | P0 | covered | `actions/_local-file-documents.test.ts`, `actions/local-folder-source.db.test.ts` | `local-file-source-truth` | - | -| notion.route-backed-document-sync | source-sync | Notion document sync status, link, unlink, pull, push, resolve, create, search, and disconnect | action-backed | `connect-notion-status`, `create-and-link-notion-page`, `disconnect-notion`, `link-notion-page`, `list-notion-links`, `pull-notion-page`, `push-notion-page`, `refresh-notion-sync-status`, `resolve-notion-sync-conflict`, `search-notion-pages`, `sync-notion-comments`, `unlink-notion-page` | `app/hooks/use-notion.ts`, `app/components/editor/DocumentToolbar.tsx`, `app/components/editor/NotionSyncBar.tsx`, `app/components/editor/DocumentEditor.tsx` | Notion connection state, page search, link metadata, and local/remote document body sync state are read or mutated through Content actions. | Notion OAuth auth-url and callback routes remain route-shaped because they initiate and receive browser redirects rather than normal app data mutations. | - | P0 | covered | `parity/__tests__/matrix-route-gap-classify.test.ts` | - | - | -| sharing.document-discoverability-and-export | sharing | Share, hide from search, export, and reveal documents | action-backed | `export-document`, `reveal-local-source-file`, `set-document-discoverability`, `share-local-file-document` | `app/components/editor/DocumentToolbar.tsx`, `app/hooks/use-documents.ts` | Search discoverability, shareable copies, exports, and OS reveal requests are managed through Content actions. | - | - | P0 | covered | `actions/_local-file-documents.test.ts` | `local-file-source-truth` | - | -| sharing.os-reveal-local-source | sharing | Reveal a local source file in the system file manager | host-only | `reveal-local-source-file` | `app/components/editor/DocumentToolbar.tsx`, `actions/reveal-local-source-file.ts` | - | OS reveal depends on trusted local host capabilities and should not spend agent tool surface or imply portable hosted behavior. | - | P2 | seeded | - | - | Local folder exception/docs PR | -| sidebar.chrome-state | sidebar | Collapse sections and resize the sidebar | client-only-ephemeral | - | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/layout/Layout.tsx` | - | - | - | P2 | none | - | - | - | -| sidebar.document-tree-crud | sidebar | Create, delete, move, favorite, list, search, and open pages | action-backed | `create-document`, `clone-creative-context-document`, `delete-document`, `get-document`, `list-trashed-documents`, `list-documents`, `move-document`, `permanently-delete-document`, `restore-document`, `search-documents`, `update-document` | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/sidebar/DocumentTreeItem.tsx`, `app/components/editor/DocumentToolbar.tsx`, `app/hooks/use-documents.ts` | Document tree rows and document metadata are created, updated, deleted, moved, searched, or read. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | -| sidebar.navigation-and-screen-context | sidebar | Navigate between documents and expose current screen context | action-equivalent | `navigate`, `view-screen` | `app/components/sidebar/DocumentSidebar.tsx`, `actions/navigate.ts`, `actions/view-screen.ts` | Application navigation state is updated or read so the agent can reason about the user's current page/view. | Human navigation is router-local, while agent navigation/screen inspection uses application-state actions to produce the same workspace orientation effect. | - | P1 | seeded | - | - | - | -| source-sync.builder-body-hydration-worker | source-sync | Process queued Builder CMS body hydration work | action-backed | `process-builder-body-hydration` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Queued Builder body hydration entries are processed into readable Content document/database body state. | This action is intentionally hidden from the model with agentTool: false because it is an internal bounded queue worker; agents should use source refresh, review, and execution actions rather than manually driving hydration internals. | - | P0 | covered | `actions/_database-source-utils.test.ts` | - | - | -| source-sync.builder-cms-review-and-write-gates | source-sync | Review, stage, validate, cancel, and execute Builder CMS source writes | action-backed | `cancel-prepared-builder-source-update`, `execute-builder-source-batch`, `execute-builder-source-execution`, `prepare-builder-source-execution`, `prepare-builder-source-review`, `preview-builder-source-review`, `review-content-database-source-change-set`, `set-content-database-source-write-mode`, `stage-builder-source-bulk-update`, `stage-builder-revision`, `validate-builder-source-execution` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/components/editor/database-sources/BuilderSourceReviewDialog.tsx` | Builder source write mode, staged reviews, pre-dispatch cancellations, validation records, and bounded execution records are created through guarded actions. | - | - | P0 | covered | `actions/builder-source-review-gates.db.test.ts`, `actions/cancel-prepared-builder-source-update.db.test.ts`, `actions/execute-builder-source-execution.test.ts`, `actions/stage-builder-source-bulk-update.db.test.ts` | `builder-source-review-readonly` | - | -| source-sync.builder-documents | source-sync | List, pull, check, and push Builder docs/blog MDX documents | action-backed | `check-builder-doc`, `list-builder-docs`, `pull-builder-doc`, `push-builder-doc` | `actions/list-builder-docs.ts`, `actions/pull-builder-doc.ts`, `actions/check-builder-doc.ts`, `actions/push-builder-doc.ts` | Builder docs/blog entries can be read into Content, checked locally, and pushed through guarded Builder document actions. | - | - | P1 | seeded | - | - | - | -| source-sync.builder-required-field-materialization | source-sync | Add required Builder publishing fields to a connected collection | action-backed | `materialize-builder-required-fields` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Required Builder fields are materialized as editable Content properties in one local mutation. | This bounded safe-model setup action is intentionally hidden from the agent tool list; the visible source settings surface invokes it. | - | P1 | covered | `actions/materialize-builder-required-fields.test.ts` | - | - | -| source-sync.database-source-bindings | source-sync | Attach, inspect, refresh, disconnect, join, and bind database sources | action-backed | `add-content-database-source-field-property`, `attach-content-database-source`, `bind-content-database-source-field`, `change-content-database-source-role`, `disconnect-content-database-source`, `get-content-database-source`, `list-builder-cms-models`, `list-notion-database-sources`, `preview-content-database-source-attach`, `refresh-content-database-source`, `suggest-source-join-key` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/DocumentProperties.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Mounted database source metadata, fields, source role, join keys, and source-field/property bindings are stored and refreshed. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | -| source-sync.provider-api-and-staged-datasets | source-sync | Inspect provider APIs and stage/query/delete large provider datasets | action-backed | `delete-staged-dataset`, `list-staged-datasets`, `provider-api-catalog`, `provider-api-docs`, `provider-api-request`, `query-staged-dataset` | `actions/provider-api-catalog.ts`, `actions/provider-api-docs.ts`, `actions/provider-api-request.ts`, `actions/query-staged-dataset.ts` | Provider API metadata and staged dataset scratch storage support scoped agent/source analysis. | - | - | P1 | seeded | - | - | - | -| versions.history-and-restore | versions | Open version history and restore a previous document version | action-backed | `list-document-versions`, `restore-document-version` | `app/components/editor/VersionHistoryPanel.tsx`, `app/hooks/use-document-versions.ts` | Document versions are listed and selected versions can restore the document while snapshotting current state. | - | - | P0 | seeded | - | - | - | -| workspace.root-landing-resolver | workspace | Resolve the app root to the caller's last authorized page or a private welcome page | action-backed | `resolve-content-landing` | `app/routes/_app.home.tsx`, `app/lib/content-landing.ts` | The root route restores the most recent authorized page when possible and otherwise converges on one private personal welcome page while preserving last-location state. | - | - | P0 | covered | `actions/resolve-content-landing.db.test.ts`, `app/lib/content-landing.test.ts` | - | - | -| workspace.spaces-and-files-catalog | workspace | Provision, navigate, and delete Content spaces through Files and Workspaces with personal expansion state | action-backed | `backfill-content-files`, `create-content-space`, `delete-content-space`, `ensure-content-spaces`, `get-content-sidebar-state`, `list-content-spaces`, `update-content-sidebar-state` | `app/components/sidebar/DocumentSidebar.tsx`, `app/hooks/use-content-spaces.ts` | Personal and organization spaces, user-created workspaces, their canonical Files databases, the personal Workspaces catalog, and each user's sidebar expansion state are stored and reconciled in SQL; deleting a user-created workspace atomically removes its catalog row and contents. | - | - | P0 | covered | `actions/content-spaces.db.test.ts`, `actions/content-files.db.test.ts`, `actions/content-sidebar-state.test.ts` | - | - | +| ID | Surface | User-visible action | Status | Actions | UI entrypoints | Durable effect | Exception / gap | Reliability risk | Spine priority | Test coverage | Coverage refs | Eval scenarios | Follow-up | +| -------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | -------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- | ------------------------------ | +| comments.threads | comments | List, add, reply, resolve, reopen, and delete comment threads | action-backed | `add-comment`, `delete-comment`, `list-comments`, `update-comment` | `app/components/editor/CommentsSidebar.tsx`, `app/hooks/use-comments.ts` | Comment threads, replies, anchors, mentions, resolution state, and deletion are stored through comment actions. | - | - | P0 | seeded | - | - | - | +| database.form-submissions | database | Submit public database forms as new rows | action-backed | `submit-content-database-form` | `app/components/editor/database/FormView.tsx` | A validated form submission atomically creates a database row document and its editable property values. | - | - | P0 | covered | `actions/submit-content-database-form.db.test.ts` | - | - | +| database.lifecycle-and-trash | database | Create, soft-delete, restore, list, and inspect content databases | action-backed | `create-content-database`, `create-inline-content-database`, `delete-content-database`, `describe-content-database`, `get-content-database`, `list-content-databases`, `list-trashed-content-databases`, `restore-content-database` | `app/components/editor/SlashCommandMenu.tsx`, `app/hooks/use-content-database.ts`, `app/hooks/use-documents.ts` | Database pages and database records are created, read, soft-deleted, restored, and listed. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/list-content-databases.db.test.ts`, `server/plugins/agent-chat.spec.ts`, `../../packages/core/src/server/agent-chat/content-a2a-capabilities.spec.ts` | `database-source-scope` | - | +| database.private-preview-drafts | database | Persist and reconcile a user's private database-page preview draft | action-backed | `get-preview-document-draft`, `update-preview-document-draft` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-documents.ts` | A user's private preview draft is read, saved, conflict-checked, or deleted without changing the shared database page until the normal save flow applies it. | These per-user editor-state actions are intentionally hidden from agent tools because preview drafts are a private UI recovery mechanism. | - | P1 | covered | `actions/preview-document-draft.db.test.ts` | - | - | +| database.properties-and-view-config | database | Configure properties, values, ordering, and saved views | action-backed | `configure-document-property`, `delete-document-property`, `duplicate-document-property`, `get-content-database-personal-view`, `list-document-properties`, `reorder-document-property`, `set-document-property`, `update-content-database-personal-view`, `update-content-database-view` | `app/components/editor/DocumentProperties.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-document-properties.ts` | Property schemas, property values, property order, filters, sorts, grouping, hidden columns, view type, and view settings are stored. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | +| database.rows | database | Add, duplicate, move, open, edit, and remove database rows | action-backed | `add-database-item`, `update-database-item`, `upsert-database-item-by-key`, `list-content-database-blocks`, `mutate-content-database-block`, `remove-database-items`, `duplicate-database-items`, `duplicate-database-item`, `update-database-items`, `migrate-content-database-rows`, `manage-content-database-migration`, `move-database-item`, `set-document-property` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx` | Database row memberships and ordering are created, duplicated, moved, edited, and removed without deleting the backing page; bounded migrations atomically update row bodies and properties through the same canonical data model. | - | - | P0 | covered | `actions/database-row-batch-actions.db.test.ts`, `actions/upsert-database-item-by-key.db.test.ts`, `actions/migrate-content-database-rows.db.test.ts`, `actions/content-database-block-actions.db.test.ts`, `parity/__tests__/database-row-batch-reliability.test.ts` | `database-bulk-row-reliability` | - | +| database.table-query-page | database | Query one constrained page while retaining database metadata | action-backed | `query-content-database-items` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | - | This UI-only bounded projection is intentionally hidden with agentTool: false; agents use get-content-database for the complete database contract. | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `app/hooks/use-content-database.test.ts` | - | - | +| database.typed-relationships | database | Configure, inspect, edit, remove, and restore typed Page relationships | action-backed | `configure-content-relation-property`, `list-content-relation-candidates`, `list-content-relationship-history`, `list-content-relationship-types`, `list-content-relationships`, `mutate-content-relationships`, `prepare-content-relationship-removal`, `remove-content-relation-property`, `undo-content-relationship-revision` | `app/components/editor/ContentRelationships.tsx`, `app/components/editor/RelationPropertyConfigurationDialog.tsx`, `app/components/editor/DocumentProperties.tsx`, `app/hooks/use-content-relationships.ts` | Canonical typed Page relationships, Relation Property projections, committed history, and reversible removals are stored through one access-scoped action surface. | - | - | P0 | covered | `actions/relationship-services.db.test.ts`, `actions/relationship-undo.db.test.ts`, `actions/relationship-pagination.db.test.ts`, `actions/canonical-relation-integration.db.test.ts`, `app/components/editor/ContentRelationships.test.ts`, `app/hooks/use-content-relationships.test.ts` | - | - | +| editor.agent-assist-prompts | editor | Ask AI from slash generation or comment context | client-assist | - | `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/CommentsSidebar.tsx` | No direct durable mutation; the prompt asks the agent to use document actions when it decides to write. | - | - | P1 | none | - | - | - | +| editor.blocks-field-word-count | editor | Inspect per-field word counts | action-backed | `get-blocks-field-word-count` | `app/components/editor/DocumentInfoPanel.tsx` | Authorized Blocks-field word counts read the current field without combining sibling fields. | - | - | P1 | covered | `actions/get-blocks-field-word-count.test.ts`, `app/components/editor/DocumentInfoPanel.test.ts` | - | - | +| editor.client-formatting-and-insertions | editor | Rich text formatting, selection state, slash block insertion, and copy actions | client-only-ephemeral | - | `app/components/editor/BubbleToolbar.tsx`, `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/DocumentToolbar.tsx` | - | - | - | P1 | none | - | - | - | +| editor.document-body-and-title | editor | Edit document title, body, icon, image alt text, and precise text | action-backed | `edit-document`, `pull-document`, `set-image-alt-text`, `transcribe-media`, `update-document` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/extensions/ImageBlock.tsx`, `app/components/editor/SlashCommandMenu.tsx` | Document content, title, icon, image metadata, and text replacements are saved to the same document source. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | +| local-files.components-workspace | local-files | Register, list, and write local MDX component workspaces | host-only | `list-local-component-files`, `register-local-component-workspace`, `write-local-component-file` | `app/routes/_app.local-files.tsx`, `actions/register-local-component-workspace.ts`, `actions/list-local-component-files.ts`, `actions/write-local-component-file.ts` | Trusted local component workspace registration and component file reads/writes support local MDX previews. | Workspace registration depends on a trusted Desktop folder path and is intentionally hidden with agentTool: false. | - | P1 | seeded | - | - | Local folder exception/docs PR | +| local-files.host-folder-handles | local-files | Choose, persist, remove, and write trusted local folder handles | host-only | - | `app/routes/_app.local-files.tsx` | Host directory handles and browser/Desktop write permissions are managed outside SQL action state. | Mounted local folders require browser/Desktop host handles that agents cannot safely or portably hold as normal tools. | - | P0 | none | - | - | Local folder exception/docs PR | +| local-files.import-export-mounted-folder | local-files | Import, check, export, push, and remove local folder source files | action-backed | `connect-local-folder-source`, `disconnect-local-folder-source`, `export-content-source`, `import-content-source`, `remove-local-file-source`, `resolve-local-folder-conflict`, `sync-local-folder-source`, `sync-manifest-local-folder-source` | `app/routes/_app.local-files.tsx`, `actions/import-content-source.ts`, `actions/export-content-source.ts` | Local Markdown/MDX source files are imported into Content documents, editable Content documents are exported back to source-friendly files, and imported source entries can be removed without deleting files on disk. | - | - | P0 | covered | `actions/_local-file-documents.test.ts`, `actions/local-folder-source.db.test.ts` | `local-file-source-truth` | - | +| notion.route-backed-document-sync | source-sync | Notion document sync status, link, unlink, pull, push, resolve, create, search, and disconnect | action-backed | `connect-notion-status`, `create-and-link-notion-page`, `disconnect-notion`, `link-notion-page`, `list-notion-links`, `pull-notion-page`, `push-notion-page`, `refresh-notion-sync-status`, `resolve-notion-sync-conflict`, `search-notion-pages`, `sync-notion-comments`, `unlink-notion-page` | `app/hooks/use-notion.ts`, `app/components/editor/DocumentToolbar.tsx`, `app/components/editor/NotionSyncBar.tsx`, `app/components/editor/DocumentEditor.tsx` | Notion connection state, page search, link metadata, and local/remote document body sync state are read or mutated through Content actions. | Notion OAuth auth-url and callback routes remain route-shaped because they initiate and receive browser redirects rather than normal app data mutations. | - | P0 | covered | `parity/__tests__/matrix-route-gap-classify.test.ts` | - | - | +| sharing.document-discoverability-and-export | sharing | Share, hide from search, export, and reveal documents | action-backed | `export-document`, `reveal-local-source-file`, `set-document-discoverability`, `share-local-file-document` | `app/components/editor/DocumentToolbar.tsx`, `app/hooks/use-documents.ts` | Search discoverability, shareable copies, exports, and OS reveal requests are managed through Content actions. | - | - | P0 | covered | `actions/_local-file-documents.test.ts` | `local-file-source-truth` | - | +| sharing.os-reveal-local-source | sharing | Reveal a local source file in the system file manager | host-only | `reveal-local-source-file` | `app/components/editor/DocumentToolbar.tsx`, `actions/reveal-local-source-file.ts` | - | OS reveal depends on trusted local host capabilities and should not spend agent tool surface or imply portable hosted behavior. | - | P2 | seeded | - | - | Local folder exception/docs PR | +| sidebar.chrome-state | sidebar | Collapse sections and resize the sidebar | client-only-ephemeral | - | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/layout/Layout.tsx` | - | - | - | P2 | none | - | - | - | +| sidebar.document-tree-crud | sidebar | Create, delete, move, favorite, list, search, and open pages | action-backed | `create-document`, `clone-creative-context-document`, `delete-document`, `get-document`, `list-trashed-documents`, `list-documents`, `move-document`, `permanently-delete-document`, `restore-document`, `search-documents`, `update-document` | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/sidebar/DocumentTreeItem.tsx`, `app/components/editor/DocumentToolbar.tsx`, `app/hooks/use-documents.ts` | Document tree rows and document metadata are created, updated, deleted, moved, searched, or read. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | +| sidebar.navigation-and-screen-context | sidebar | Navigate between documents and expose current screen context | action-equivalent | `navigate`, `view-screen` | `app/components/sidebar/DocumentSidebar.tsx`, `actions/navigate.ts`, `actions/view-screen.ts` | Application navigation state is updated or read so the agent can reason about the user's current page/view. | Human navigation is router-local, while agent navigation/screen inspection uses application-state actions to produce the same workspace orientation effect. | - | P1 | seeded | - | - | - | +| source-sync.builder-body-hydration-worker | source-sync | Process queued Builder CMS body hydration work | action-backed | `process-builder-body-hydration` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Queued Builder body hydration entries are processed into readable Content document/database body state. | This action is intentionally hidden from the model with agentTool: false because it is an internal bounded queue worker; agents should use source refresh, review, and execution actions rather than manually driving hydration internals. | - | P0 | covered | `actions/_database-source-utils.test.ts` | - | - | +| source-sync.builder-cms-review-and-write-gates | source-sync | Review, stage, validate, cancel, and execute Builder CMS source writes | action-backed | `cancel-prepared-builder-source-update`, `execute-builder-source-batch`, `execute-builder-source-execution`, `prepare-builder-source-execution`, `prepare-builder-source-review`, `preview-builder-source-review`, `review-content-database-source-change-set`, `set-content-database-source-write-mode`, `stage-builder-source-bulk-update`, `stage-builder-revision`, `validate-builder-source-execution` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/components/editor/database-sources/BuilderSourceReviewDialog.tsx` | Builder source write mode, staged reviews, pre-dispatch cancellations, validation records, and bounded execution records are created through guarded actions. | - | - | P0 | covered | `actions/builder-source-review-gates.db.test.ts`, `actions/cancel-prepared-builder-source-update.db.test.ts`, `actions/execute-builder-source-execution.test.ts`, `actions/stage-builder-source-bulk-update.db.test.ts` | `builder-source-review-readonly` | - | +| source-sync.builder-documents | source-sync | List, pull, check, and push Builder docs/blog MDX documents | action-backed | `check-builder-doc`, `list-builder-docs`, `pull-builder-doc`, `push-builder-doc` | `actions/list-builder-docs.ts`, `actions/pull-builder-doc.ts`, `actions/check-builder-doc.ts`, `actions/push-builder-doc.ts` | Builder docs/blog entries can be read into Content, checked locally, and pushed through guarded Builder document actions. | - | - | P1 | seeded | - | - | - | +| source-sync.builder-required-field-materialization | source-sync | Add required Builder publishing fields to a connected collection | action-backed | `materialize-builder-required-fields` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Required Builder fields are materialized as editable Content properties in one local mutation. | This bounded safe-model setup action is intentionally hidden from the agent tool list; the visible source settings surface invokes it. | - | P1 | covered | `actions/materialize-builder-required-fields.test.ts` | - | - | +| source-sync.database-source-bindings | source-sync | Attach, inspect, refresh, disconnect, join, and bind database sources | action-backed | `add-content-database-source-field-property`, `attach-content-database-source`, `bind-content-database-source-field`, `change-content-database-source-role`, `disconnect-content-database-source`, `get-content-database-source`, `list-builder-cms-models`, `list-notion-database-sources`, `preview-content-database-source-attach`, `refresh-content-database-source`, `suggest-source-join-key` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/DocumentProperties.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Mounted database source metadata, fields, source role, join keys, and source-field/property bindings are stored and refreshed. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | +| source-sync.provider-api-and-staged-datasets | source-sync | Inspect provider APIs and stage/query/delete large provider datasets | action-backed | `delete-staged-dataset`, `list-staged-datasets`, `provider-api-catalog`, `provider-api-docs`, `provider-api-request`, `query-staged-dataset` | `actions/provider-api-catalog.ts`, `actions/provider-api-docs.ts`, `actions/provider-api-request.ts`, `actions/query-staged-dataset.ts` | Provider API metadata and staged dataset scratch storage support scoped agent/source analysis. | - | - | P1 | seeded | - | - | - | +| versions.history-and-restore | versions | Open version history and restore a previous document version | action-backed | `list-document-versions`, `restore-document-version` | `app/components/editor/VersionHistoryPanel.tsx`, `app/hooks/use-document-versions.ts` | Document versions are listed and selected versions can restore the document while snapshotting current state. | - | - | P0 | seeded | - | - | - | +| workspace.root-landing-resolver | workspace | Resolve the app root to the caller's last authorized page or a private welcome page | action-backed | `resolve-content-landing` | `app/routes/_app.home.tsx`, `app/lib/content-landing.ts` | The root route restores the most recent authorized page when possible and otherwise converges on one private personal welcome page while preserving last-location state. | - | - | P0 | covered | `actions/resolve-content-landing.db.test.ts`, `app/lib/content-landing.test.ts` | - | - | +| workspace.spaces-and-files-catalog | workspace | Provision, navigate, and delete Content spaces through Files and Workspaces with personal expansion state | action-backed | `backfill-content-files`, `create-content-space`, `delete-content-space`, `ensure-content-spaces`, `get-content-sidebar-state`, `list-content-spaces`, `update-content-sidebar-state` | `app/components/sidebar/DocumentSidebar.tsx`, `app/hooks/use-content-spaces.ts` | Personal and organization spaces, user-created workspaces, their canonical Files databases, the personal Workspaces catalog, and each user's sidebar expansion state are stored and reconciled in SQL; deleting a user-created workspace atomically removes its catalog row and contents. | - | - | P0 | covered | `actions/content-spaces.db.test.ts`, `actions/content-files.db.test.ts`, `actions/content-sidebar-state.test.ts` | - | - | diff --git a/templates/content/parity/matrix.ts b/templates/content/parity/matrix.ts index af76a634f22..3d1530e2833 100644 --- a/templates/content/parity/matrix.ts +++ b/templates/content/parity/matrix.ts @@ -422,6 +422,47 @@ export const parityMatrix: ParityRow[] = [ followUpPR: null, coverageRefs: ["actions/preview-document-draft.db.test.ts"], }, + { + id: "database.typed-relationships", + surface: "database", + label: + "Configure, inspect, edit, remove, and restore typed Page relationships", + uiEntrypoints: [ + "app/components/editor/ContentRelationships.tsx", + "app/components/editor/RelationPropertyConfigurationDialog.tsx", + "app/components/editor/DocumentProperties.tsx", + "app/hooks/use-content-relationships.ts", + ], + durableEffect: + "Canonical typed Page relationships, Relation Property projections, committed history, and reversible removals are stored through one access-scoped action surface.", + uiImplementation: + "Relation Property configuration, cells, bulk edits, Connections, removal impact review, history, and Undo call the same typed relationship actions exposed to agents.", + status: "action-backed", + actions: [ + "configure-content-relation-property", + "list-content-relation-candidates", + "list-content-relationship-history", + "list-content-relationship-types", + "list-content-relationships", + "mutate-content-relationships", + "prepare-content-relationship-removal", + "remove-content-relation-property", + "undo-content-relationship-revision", + ], + exception: null, + reliabilityRisk: "none", + spinePriority: "P0", + testCoverage: "covered", + followUpPR: null, + coverageRefs: [ + "actions/relationship-services.db.test.ts", + "actions/relationship-undo.db.test.ts", + "actions/relationship-pagination.db.test.ts", + "actions/canonical-relation-integration.db.test.ts", + "app/components/editor/ContentRelationships.test.ts", + "app/hooks/use-content-relationships.test.ts", + ], + }, { id: "database.properties-and-view-config", surface: "database", From b16e542d6e732c5d61d39a00c544572c2e3c228f Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:34:53 -0400 Subject: [PATCH 4/5] fix: refresh page property values after relationship mutations --- .../app/hooks/content-action-refresh.ts | 8 +---- .../content/app/hooks/use-db-sync.spec.ts | 36 ++++++++++--------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/templates/content/app/hooks/content-action-refresh.ts b/templates/content/app/hooks/content-action-refresh.ts index cbfdea90928..365a07f0574 100644 --- a/templates/content/app/hooks/content-action-refresh.ts +++ b/templates/content/app/hooks/content-action-refresh.ts @@ -22,12 +22,6 @@ const RELATIONSHIP_MUTATIONS = new Set([ "undo-content-relationship-revision", ]); -const RELATIONSHIP_PROPERTY_MUTATIONS = new Set([ - "configure-content-relation-property", - "remove-content-relation-property", - "undo-content-relationship-revision", -]); - const RELATIONSHIP_QUERIES = new Set([ "list-content-relation-candidates", "list-content-relationship-history", @@ -209,7 +203,7 @@ export function contentActionInvalidatePredicate( typeof event.key === "string" && (CONTENT_MUTATIONS.has(event.key) || (query.queryKey[1] === "list-document-properties" && - RELATIONSHIP_PROPERTY_MUTATIONS.has(event.key))), + RELATIONSHIP_MUTATIONS.has(event.key))), ); } if (queryTargetsDatabase(query, documentId)) { diff --git a/templates/content/app/hooks/use-db-sync.spec.ts b/templates/content/app/hooks/use-db-sync.spec.ts index 88a388875e5..0ac5a30552e 100644 --- a/templates/content/app/hooks/use-db-sync.spec.ts +++ b/templates/content/app/hooks/use-db-sync.spec.ts @@ -183,25 +183,29 @@ describe("contentActionInvalidatePredicate", () => { it.each([ "configure-content-relation-property", + "mutate-content-relationships", "remove-content-relation-property", "undo-content-relationship-revision", - ])("refreshes relation Property definitions after agent action %s", (key) => { - const predicate = contentActionInvalidatePredicate("/page/database-page"); + ])( + "refreshes relation Property values or definitions after agent action %s", + (key) => { + const predicate = contentActionInvalidatePredicate("/page/database-page"); - expect( - predicate( - { - queryKey: [ - "action", - "list-document-properties", - { documentId: "database-page", databaseId: "database" }, - ], - isActive: () => true, - }, - [{ source: "action", key }], - ), - ).toBe(true); - }); + expect( + predicate( + { + queryKey: [ + "action", + "list-document-properties", + { documentId: "database-page", databaseId: "database" }, + ], + isActive: () => true, + }, + [{ source: "action", key }], + ), + ).toBe(true); + }, + ); it("refreshes an active inline database mounted on another host page", () => { const predicate = contentActionInvalidatePredicate("/page/host-document"); From 4a0a07b8f5b5785aa4e4e9e8816c8b2ccdc470cd Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:52:57 -0400 Subject: [PATCH 5/5] Simplify relationship configuration and bulk editing --- .../.agents/skills/document-editing/SKILL.md | 6 +- .../references/typed-relationships.md | 12 + .../editor/ContentRelationships.tsx | 563 +++++++----------- .../components/editor/DocumentInfoPanel.tsx | 4 +- .../components/editor/DocumentProperties.tsx | 27 +- .../RelationBulkValueEditor.ui.test.tsx | 284 +++++++++ ...lationPropertyConfigurationDialog.test.tsx | 166 ++++++ .../RelationPropertyConfigurationDialog.tsx | 48 +- .../editor/database/DatabaseView.tsx | 20 + .../editor/relationship-bulk.test.ts | 252 ++++++++ .../components/editor/relationship-bulk.ts | 272 +++++++++ .../app/hooks/use-relationship-app-state.ts | 2 +- templates/content/app/i18n-data.ts | 305 ++++++---- templates/content/app/i18n/zh-TW.ts | 26 +- .../2026-09-08-typed-page-relationships.md | 2 +- .../capabilities/content.relationship.edge.md | 31 +- 16 files changed, 1533 insertions(+), 487 deletions(-) create mode 100644 templates/content/app/components/editor/RelationBulkValueEditor.ui.test.tsx create mode 100644 templates/content/app/components/editor/RelationPropertyConfigurationDialog.test.tsx create mode 100644 templates/content/app/components/editor/relationship-bulk.test.ts create mode 100644 templates/content/app/components/editor/relationship-bulk.ts diff --git a/templates/content/.agents/skills/document-editing/SKILL.md b/templates/content/.agents/skills/document-editing/SKILL.md index dfb8a84b9c4..911232ee052 100644 --- a/templates/content/.agents/skills/document-editing/SKILL.md +++ b/templates/content/.agents/skills/document-editing/SKILL.md @@ -105,8 +105,10 @@ pnpm action permanently-delete-document --id abc123 For assignments to People Pages and other typed Page connections, follow [Typed Relationships](references/typed-relationships.md). Relation columns and -Connections share canonical edges; use the relationship Actions for changes, -including bulk work and recovery. +their optional reverse columns project canonical edges as clickable fields; use +the relationship Actions for changes, including bulk work and recovery. The +current slice has no separate Connections or Other connections list, and +History remains available independently after the last projection is removed. ## Comments diff --git a/templates/content/.agents/skills/document-editing/references/typed-relationships.md b/templates/content/.agents/skills/document-editing/references/typed-relationships.md index fb7132569d9..dda3d955b96 100644 --- a/templates/content/.agents/skills/document-editing/references/typed-relationships.md +++ b/templates/content/.agents/skills/document-editing/references/typed-relationships.md @@ -26,6 +26,18 @@ into partial commits. Retry an uncertain write with the same operation ID and same arguments. A new intended change needs a new operation ID. Verify through an authorized relationship read before declaring the assignment complete. +In the UI, Relation fields are clickable Page links. A reverse column appears +only when it is configured; without one, the relationship is not displayed on +that side. There is no separate Connections or Other connections list. If the +last projection is removed, authorized Actions can still read preserved edges +and independently available History can restore the projection. + +Bulk editing uses one list with three states: checked means linked from every +selected row, mixed means linked from some, and unchecked means linked from +none. Apply sends only net additions and observed removals through the canonical +mutation Action as one atomic request. Preserve untouched assignments, and do +not treat a read failure or incomplete observation as unchecked. + ## Configuration and recovery `configure-content-relation-property` creates a local directional type or diff --git a/templates/content/app/components/editor/ContentRelationships.tsx b/templates/content/app/components/editor/ContentRelationships.tsx index 12dcab5770e..1ae44166f6b 100644 --- a/templates/content/app/components/editor/ContentRelationships.tsx +++ b/templates/content/app/components/editor/ContentRelationships.tsx @@ -6,14 +6,12 @@ import type { ContentRelationshipHistoryChange, ContentRelationshipHistoryItem, ContentRelationshipItem, - MutateContentRelationshipsResult, RemoveContentRelationPropertyInput, RemoveContentRelationPropertyResult, RelationshipChange, RelationshipRouteRef, } from "@shared/relationships"; import { - IconArrowLeft, IconArrowRight, IconArrowsExchange, IconCheck, @@ -21,10 +19,10 @@ import { IconClockFilled, IconHistory, IconLink, + IconMinus, IconRotate, IconSearch, IconTrash, - IconX, } from "@tabler/icons-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router"; @@ -63,6 +61,15 @@ import { import { useRelationshipAppState } from "@/hooks/use-relationship-app-state"; import { cn } from "@/lib/utils"; +import { + effectiveRelationshipBulkSelectionState, + planRelationshipBulkChanges, + relationshipBulkCandidates, + relationshipBulkReadStatus, + toggleRelationshipBulkIntent, + type RelationshipBulkIntent, +} from "./relationship-bulk"; + export function relationshipOppositeEndpoint( edge: ContentRelationshipItem, pageId: string, @@ -340,10 +347,14 @@ export function RelationValueSummary({ property, pageId, fallback, + navigable = false, + showAll = false, }: { property: DocumentProperty; pageId: string; fallback?: React.ReactNode; + navigable?: boolean; + showAll?: boolean; }) { const t = useT(); const relation = canonicalRelationOptions(property); @@ -379,7 +390,7 @@ export function RelationValueSummary({ return ( - {items.slice(0, 3).map((edge) => { + {(showAll ? items : items.slice(0, 3)).map((edge) => { const endpoint = relationshipOppositeEndpoint(edge, pageId); return ( - {endpoint.title} + {navigable ? ( + + {endpoint.title} + + ) : ( + {endpoint.title} + )} {edge.state !== "active" ? ( · {t(`relationships.states.${edge.state}`)} @@ -398,7 +418,7 @@ export function RelationValueSummary({ ); })} - {items.length > 3 ? ( + {!showAll && items.length > 3 ? ( {t("relationships.moreCount", { count: items.length - 3 })} @@ -773,142 +793,25 @@ export function RelationValueEditor({ ); } -function ConnectionRow({ - edge, +export function ContentRelationshipHistorySection({ pageId, - onRemove, - pending, }: { - edge: ContentRelationshipItem; pageId: string; - onRemove: (edge: ContentRelationshipItem) => void; - pending: boolean; }) { - const t = useT(); - const endpoint = relationshipOppositeEndpoint(edge, pageId); - const canRemove = - edge.routes.length > 0 && edge.observedActivationIds.length > 0; - return ( -
- {edge.direction === "outgoing" ? ( - - ) : ( - - )} -
-
- {edge.relationship.label} -
- - {endpoint.title} - -
- {edge.state !== "active" ? ( - - {t(`relationships.states.${edge.state}`)} - - ) : null} - {canRemove ? ( - - ) : null} -
- ); -} - -export function ContentConnectionsSection({ pageId }: { pageId: string }) { const t = useT(); const formatters = useFormatters(); - const relationships = useContentRelationships({ - pageId, - direction: "both", - limit: 100, - }); const history = useContentRelationshipHistory({ pageId, limit: 50 }); - const mutate = useMutateContentRelationships(); const undo = useUndoContentRelationshipRevision(); const [historyOpen, setHistoryOpen] = useState(false); const [error, setError] = useState(null); - useRelationshipAppState({ - pageId, - surface: historyOpen ? "history" : "connections", - }); - - async function finishRemoval( - result: MutateContentRelationshipsResult, - routes: RelationshipRouteRef[], - ) { - const refreshedHistory = await history.refetch(); - const recovery = refreshedHistory.data?.items.find( - (item) => item.revisionId === result.revisionId, - )?.recovery.recoveryToken; - toast.success( - t("relationships.connectionRemoved"), - recovery - ? { - action: { - label: t("relationships.undo"), - onClick: () => { - void undoHistory(result.revisionId, recovery, routes); - }, - }, - } - : undefined, - ); - } - - async function remove(edge: ContentRelationshipItem) { - const route = edge.routes[0]; - if (!route || edge.observedActivationIds.length === 0) return; - undo.clearFailedRequest(); - setError(null); - try { - const result = await mutate.mutateAsync({ - operationId: contentRelationshipOperationId(), - changes: [ - { - kind: "remove", - edgeId: edge.edgeId, - observedActivationIds: edge.observedActivationIds, - observationToken: edge.observationToken, - route, - }, - ], - }); - await finishRemoval(result, [route]); - } catch (caught) { - if (isSupersededRelationshipMutationError(caught)) return; - setError( - relationshipMutationErrorMessage( - caught, - t("relationships.requestInterrupted"), - t("relationships.removeFailed"), - ), - ); - } - } + useRelationshipAppState(historyOpen ? { pageId, surface: "history" } : null); async function undoHistory( revisionId: string, recoveryToken: string, routes: RelationshipRouteRef[] = [], ) { - mutate.clearFailedRequest(); setError(null); try { await undo.mutateAsync({ @@ -930,86 +833,31 @@ export function ContentConnectionsSection({ pageId }: { pageId: string }) { } } - async function retryConnectionChange() { + async function retryHistoryChange() { setError(null); try { - if (mutate.failedVariables) { - const failedRequest = mutate.failedVariables; - const result = await mutate.retryFailed(); - const routes = failedRequest.changes.map((change) => change.route); - await finishRemoval(result, routes); - return; - } - if (undo.failedVariables) { - await undo.retryFailed(); - toast.success(t("relationships.changeUndone")); - } + await undo.retryFailed(); + toast.success(t("relationships.changeUndone")); } catch (caught) { if (isSupersededRelationshipMutationError(caught)) return; setError( relationshipMutationErrorMessage( caught, t("relationships.requestInterrupted"), - mutate.failedVariables - ? t("relationships.removeFailed") - : t("relationships.undoFailed"), + t("relationships.undoFailed"), ), ); } } return ( -
-
-

- {t("relationships.connections")} -

- {(relationships.data?.items.length ?? 0) > 0 ? ( - - {formatters.formatNumber(relationships.data?.items.length ?? 0)} - - ) : null} -
- {relationships.isLoading && !relationships.data ? ( -
- - -
- ) : relationships.isError ? ( - - ) : relationships.data?.items.length ? ( -
- {relationships.data.items.map((edge) => ( - void remove(item)} - /> - ))} -
- ) : ( -
- {t("relationships.noConnections")} -
- )} +
{error ? ( void retryConnectionChange() - : undefined + undo.failedVariables ? () => void retryHistoryChange() : undefined } className="px-2 py-1 text-xs" /> @@ -1151,8 +999,11 @@ export function RelationBulkValueEditor({ : null, ); const mutate = useMutateContentRelationships(); - const [mode, setMode] = useState<"add" | "remove">("add"); - const [selectedOppositePageId, setSelectedOppositePageId] = useState(""); + const [bulkEdit, setBulkEdit] = useState<{ + intent: RelationshipBulkIntent; + observedEdges: ContentRelationshipItem[] | null; + observedCandidates: ContentRelationCandidate[]; + }>({ intent: {}, observedEdges: null, observedCandidates: [] }); const [error, setError] = useState(null); useRelationshipAppState( @@ -1176,28 +1027,19 @@ export function RelationBulkValueEditor({ ? selectedSet.has(edge.targetPageId) : selectedSet.has(edge.sourcePageId), ); - const removeCandidates = Array.from( - new Map( - relevantEdges.map((edge) => { - const anchor = - relation?.direction === "inverse" - ? edge.targetPageId - : edge.sourcePageId; - const endpoint = relationshipOppositeEndpoint(edge, anchor); - return [ - endpoint.pageId, - { - pageId: endpoint.pageId, - title: endpoint.title, - context: {}, - slotObservationToken: null, - }, - ]; - }), - ).values(), - ); - const availableCandidates = - mode === "add" ? (candidates.data?.items ?? []) : removeCandidates; + const candidateInputs = [ + ...(candidates.data?.items ?? []), + ...bulkEdit.observedCandidates, + ]; + const selectionEdges = bulkEdit.observedEdges ?? relevantEdges; + const availableCandidates = relation + ? relationshipBulkCandidates({ + candidates: candidateInputs, + edges: selectionEdges, + selectedPageIds, + direction: relation.direction, + }) + : []; const normalizedQuery = query.trim().toLowerCase(); const visibleCandidates = availableCandidates.filter( (candidate) => @@ -1207,87 +1049,64 @@ export function RelationBulkValueEditor({ const typeDescriptor = types.data?.items.find( (item) => item.type.id === relation?.relationshipTypeId, ); - const selectedCandidate = availableCandidates.find( - (candidate) => candidate.pageId === selectedOppositePageId, - ); + const readStatus = relationshipBulkReadStatus([ + relationships, + candidates, + types, + ]); + const hasChanges = Object.keys(bulkEdit.intent).length > 0; async function apply() { - if (!relation || !typeDescriptor || !selectedOppositePageId) return; + if (!relation || !typeDescriptor || readStatus !== "ready") return; setError(null); try { + const plan = planRelationshipBulkChanges({ + candidates: availableCandidates, + edges: selectionEdges, + selectedPageIds, + direction: relation.direction, + forwardCardinality: typeDescriptor.version.forwardCardinality, + intent: bulkEdit.intent, + }); + if (plan.error === "inverse-max-one") { + throw new Error(t("relationships.bulkInverseMaxOne")); + } + if (plan.error === "refresh-required") { + throw new Error(t("relationships.refreshBeforeReplacing")); + } const changes: RelationshipChange[] = []; - if (mode === "add") { - if ( - relation.direction === "inverse" && - typeDescriptor.version.forwardCardinality === "one" && - selectedPageIds.length > 1 - ) { - throw new Error(t("relationships.bulkInverseMaxOne")); - } - for (const pageId of selectedPageIds) { - const route = propertyRoute(property, pageId); - if (!route) throw new Error(t("relationships.routeUnavailable")); - const sourcePageId = - relation.direction === "forward" ? pageId : selectedOppositePageId; - const targetPageId = - relation.direction === "forward" ? selectedOppositePageId : pageId; - const current = relevantEdges.find( - (edge) => - relation.direction === "forward" && edge.sourcePageId === pageId, - ); - if ( - typeDescriptor.version.forwardCardinality === "one" && - (relation.direction === "inverse" || - (current && - relationshipOppositeEndpoint(current, pageId).pageId !== - selectedOppositePageId)) - ) { - const slotObservationToken = - relation.direction === "inverse" - ? selectedCandidate?.slotObservationToken - : current?.slotObservationToken; - if (!slotObservationToken) { - throw new Error(t("relationships.refreshBeforeReplacing")); - } - changes.push({ - kind: "replace", - typeId: relation.relationshipTypeId, - typeVersionId: typeDescriptor.version.id, - sourcePageId, - targetPageId, - observedSlotToken: slotObservationToken, - route, - }); - } else { - changes.push({ - kind: "add", - typeId: relation.relationshipTypeId, - typeVersionId: typeDescriptor.version.id, - sourcePageId, - targetPageId, - route, - }); - } - } - } else { - for (const edge of relevantEdges) { - const anchor = - relation.direction === "forward" - ? edge.sourcePageId - : edge.targetPageId; - if ( - relationshipOppositeEndpoint(edge, anchor).pageId !== - selectedOppositePageId - ) { - continue; - } - const route = edgePropertyRoute(edge, property); + for (const item of plan.items) { + if (item.kind === "remove") { + const route = edgePropertyRoute(item.edge, property); if (!route) throw new Error(t("relationships.routeUnavailable")); changes.push({ kind: "remove", - edgeId: edge.edgeId, - observedActivationIds: edge.observedActivationIds, - observationToken: edge.observationToken, + edgeId: item.edge.edgeId, + observedActivationIds: item.edge.observedActivationIds, + observationToken: item.edge.observationToken, + route, + }); + continue; + } + const route = propertyRoute(property, item.anchorPageId); + if (!route) throw new Error(t("relationships.routeUnavailable")); + if (item.kind === "replace") { + changes.push({ + kind: "replace", + typeId: relation.relationshipTypeId, + typeVersionId: typeDescriptor.version.id, + sourcePageId: item.sourcePageId, + targetPageId: item.targetPageId, + observedSlotToken: item.observedSlotToken, + route, + }); + } else { + changes.push({ + kind: "add", + typeId: relation.relationshipTypeId, + typeVersionId: typeDescriptor.version.id, + sourcePageId: item.sourcePageId, + targetPageId: item.targetPageId, route, }); } @@ -1298,12 +1117,7 @@ export function RelationBulkValueEditor({ changes, }); toast.success( - t( - mode === "add" - ? "relationships.bulkAdded" - : "relationships.bulkRemoved", - { count: result.results.length }, - ), + t("relationships.bulkUpdated", { count: result.results.length }), ); onDone(); } catch (caught) { @@ -1323,12 +1137,7 @@ export function RelationBulkValueEditor({ try { const result = await mutate.retryFailed(); toast.success( - t( - mode === "add" - ? "relationships.bulkAdded" - : "relationships.bulkRemoved", - { count: result.results.length }, - ), + t("relationships.bulkUpdated", { count: result.results.length }), ); onDone(); } catch (caught) { @@ -1352,29 +1161,6 @@ export function RelationBulkValueEditor({ } return (
-
- {(["add", "remove"] as const).map((value) => ( - - ))} -
- {visibleCandidates.length === 0 ? ( -
- {mode === "remove" - ? t("relationships.noSharedRelationships") - : t("relationships.noMatchingPages")} + {readStatus === "loading" ? ( +
+ +
- ) : ( - visibleCandidates.map((candidate) => ( - - )) + {t("relationships.tryAgain")} + +
+ ) : visibleCandidates.length === 0 ? ( +
+ {t("relationships.noMatchingPages")} +
+ ) : ( + visibleCandidates.map((candidate) => { + const selectionState = effectiveRelationshipBulkSelectionState( + candidate, + bulkEdit.intent, + ); + const checkboxId = `bulk-relation-${property.definition.id}-${candidate.pageId}`; + return ( + + ); + }) )}
{error ? ( @@ -1433,7 +1302,13 @@ export function RelationBulkValueEditor({
{!isLocalFileDocument ? ( - + ) : null} {document.databaseMembership && !isLocalFileDocument ? ( {property.definition.type === "relation" ? ( ) : ( @@ -1072,7 +1075,22 @@ function PropertyRow({ )}
)} - {canEditValues && property.editable ? ( + {canonicalRelation ? ( +
+ {value} + {canEditValues && property.editable ? ( + + + + ) : null} +
+ ) : canEditValues && property.editable ? ( {children} diff --git a/templates/content/app/components/editor/RelationBulkValueEditor.ui.test.tsx b/templates/content/app/components/editor/RelationBulkValueEditor.ui.test.tsx new file mode 100644 index 00000000000..6a9ff78bf76 --- /dev/null +++ b/templates/content/app/components/editor/RelationBulkValueEditor.ui.test.tsx @@ -0,0 +1,284 @@ +// @vitest-environment happy-dom + +import type { ContentDatabaseItem, DocumentProperty } from "@shared/api"; +import type { ContentRelationshipItem } from "@shared/relationships"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const relationshipMocks = vi.hoisted(() => ({ + relationships: {} as Record, + candidates: {} as Record, + types: {} as Record, + mutateAsync: vi.fn(), + clearFailedRequest: vi.fn(), +})); + +vi.mock("@agent-native/core/client/i18n", () => ({ + useFormatters: () => ({ + formatDate: (value: string) => value, + formatNumber: (value: number) => String(value), + }), + useT: () => (key: string) => + ({ + "relationships.loadFailed": "Relationships could not be loaded.", + "relationships.tryAgain": "Try again", + "relationships.searchPages": "Search pages...", + "relationships.noMatchingPages": "No matching pages", + "relationships.cancel": "Cancel", + "relationships.apply": "Apply", + })[key] ?? key, +})); + +vi.mock("@/hooks/use-relationship-app-state", () => ({ + useRelationshipAppState: () => undefined, +})); + +vi.mock("@/hooks/use-content-relationships", () => ({ + canonicalRelationOptions: () => ({ + kind: "canonical", + relationshipTypeId: "contributors", + projectionId: "contributors-property", + direction: "forward", + editable: true, + }), + contentRelationshipOperationId: () => "operation-1", + isSupersededRelationshipMutationError: () => false, + relationshipMutationErrorMessage: (error: Error) => error.message, + useContentRelationships: () => relationshipMocks.relationships, + useContentRelationCandidates: () => relationshipMocks.candidates, + useContentRelationshipTypes: () => relationshipMocks.types, + useMutateContentRelationships: () => ({ + mutateAsync: relationshipMocks.mutateAsync, + retryFailed: vi.fn(), + clearFailedRequest: relationshipMocks.clearFailedRequest, + failedVariables: null, + isPending: false, + }), + useContentRelationshipHistory: vi.fn(), + usePrepareContentRelationshipRemoval: vi.fn(), + useRemoveContentRelationProperty: vi.fn(), + useUndoContentRelationshipRevision: vi.fn(), +})); + +import { RelationBulkValueEditor } from "./ContentRelationships"; + +const property = { + definition: { + id: "contributors-property", + databaseId: "deliverables", + name: "Contributors", + type: "relation", + options: {}, + }, + value: null, + editable: true, +} as DocumentProperty; + +const selectedItems = ["launch", "social"].map( + (pageId) => + ({ + id: `item-${pageId}`, + databaseId: "deliverables", + document: { + id: pageId, + parentId: null, + title: pageId, + content: "", + icon: null, + position: 0, + isFavorite: false, + hideFromSearch: false, + createdAt: "2026-09-09T00:00:00.000Z", + updatedAt: "2026-09-09T00:00:00.000Z", + }, + position: 0, + properties: [], + }) satisfies ContentDatabaseItem, +); + +function edge( + sourcePageId = "launch", + activationIds = [`activation-${sourcePageId}`], +): ContentRelationshipItem { + return { + edgeId: `${sourcePageId}-mira`, + lineageId: `${sourcePageId}-mira`, + typeId: "contributors", + typeVersionId: "contributors-v1", + sourcePageId, + targetPageId: "mira", + direction: "outgoing", + state: "active", + observedActivationIds: activationIds, + observationToken: `observation-${activationIds.join("-")}`, + slotObservationToken: `slot-${sourcePageId}`, + source: { pageId: sourcePageId, title: sourcePageId, state: "active" }, + target: { pageId: "mira", title: "Mira", state: "active" }, + relationship: { + forwardLabel: "Contributors", + inverseLabel: "Deliverables", + label: "Contributors", + forwardCardinality: "many", + }, + routes: [ + { + kind: "forward-property", + propertyId: "contributors-property", + sourcePageId, + }, + ], + }; +} + +function readyQuery(data: unknown) { + return { + data, + isError: false, + isPlaceholderData: false, + refetch: vi.fn(), + }; +} + +describe("RelationBulkValueEditor", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + relationshipMocks.mutateAsync.mockReset(); + relationshipMocks.mutateAsync.mockResolvedValue({ results: [] }); + relationshipMocks.clearFailedRequest.mockReset(); + relationshipMocks.candidates = readyQuery({ + scope: "viewer-accessible", + items: [ + { + pageId: "mira", + title: "Mira", + context: {}, + slotObservationToken: null, + }, + ], + slotObservationToken: null, + nextCursor: null, + }); + relationshipMocks.types = readyQuery({ + scope: "viewer-accessible", + items: [ + { + type: { id: "contributors" }, + version: { id: "contributors-v1", forwardCardinality: "many" }, + projections: [], + capabilities: {}, + }, + ], + nextCursor: null, + }); + }); + + afterEach(() => { + act(() => root.unmount()); + document.body.replaceChildren(); + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = false; + }); + + function render() { + act(() => { + root.render( + , + ); + }); + } + + it("visibly blocks an errored baseline instead of rendering unchecked rows", () => { + relationshipMocks.relationships = { + data: undefined, + isError: true, + isPlaceholderData: false, + refetch: vi.fn(), + }; + render(); + + expect(container.textContent).toContain( + "Relationships could not be loaded.", + ); + expect(container.querySelector('[role="checkbox"]')).toBeNull(); + expect( + ( + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Apply", + ) as HTMLButtonElement + ).disabled, + ).toBe(true); + }); + + it("renders a dash for mixed membership", () => { + relationshipMocks.relationships = readyQuery({ + scope: "viewer-accessible", + items: [edge()], + nextCursor: null, + }); + render(); + + const checkbox = container.querySelector( + '[role="checkbox"]', + ) as HTMLElement; + expect(checkbox.getAttribute("aria-checked")).toBe("mixed"); + expect( + container.querySelector("[data-relationship-bulk-mixed]"), + ).not.toBeNull(); + }); + + it("freezes observed removals before a concurrent activation appears", async () => { + relationshipMocks.relationships = readyQuery({ + scope: "viewer-accessible", + items: [edge("launch"), edge("social")], + nextCursor: null, + }); + render(); + + const checkbox = container.querySelector( + '[role="checkbox"]', + ) as HTMLElement; + expect(checkbox.getAttribute("aria-checked")).toBe("true"); + await act(async () => checkbox.click()); + relationshipMocks.relationships = readyQuery({ + scope: "viewer-accessible", + items: [ + edge("launch", ["activation-launch", "activation-concurrent"]), + edge("social"), + ], + nextCursor: null, + }); + render(); + const apply = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Apply", + )!; + await act(async () => apply.click()); + + expect(relationshipMocks.mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: "operation-1", + changes: expect.arrayContaining([ + expect.objectContaining({ + kind: "remove", + edgeId: "launch-mira", + observedActivationIds: ["activation-launch"], + }), + ]), + }), + ); + }); +}); diff --git a/templates/content/app/components/editor/RelationPropertyConfigurationDialog.test.tsx b/templates/content/app/components/editor/RelationPropertyConfigurationDialog.test.tsx new file mode 100644 index 00000000000..962ca825b46 --- /dev/null +++ b/templates/content/app/components/editor/RelationPropertyConfigurationDialog.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { messagesByLocale } from "../../i18n-data"; + +const configurationMutation = vi.hoisted(() => ({ + clearFailedRequest: vi.fn(), + failedVariables: null, + isPending: false, + mutateAsync: vi.fn(async () => ({})), + retryFailed: vi.fn(async () => ({})), +})); + +vi.mock("@agent-native/core/client/i18n", () => ({ + useT: () => (key: string, options?: Record) => { + const name = key.replace("relationships.", "") as keyof typeof copy; + let value = String(copy[name] ?? key); + for (const [placeholder, replacement] of Object.entries(options ?? {})) { + value = value.split(`{{${placeholder}}}`).join(String(replacement)); + } + return value; + }, +})); + +vi.mock("@/hooks/use-content-database", () => ({ + useContentDatabases: () => ({ + data: { + databases: [ + { databaseId: "campaigns", title: "Campaign Deliverables" }, + { databaseId: "people", title: "Marketing Team" }, + ], + }, + isError: false, + isLoading: false, + refetch: vi.fn(), + }), +})); + +vi.mock("@/hooks/use-content-relationships", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/hooks/use-content-relationships") + >()), + useConfigureContentRelationProperty: () => configurationMutation, + useContentRelationshipTypes: () => ({ + data: { items: [] }, + isError: false, + isLoading: false, + refetch: vi.fn(), + }), +})); + +vi.mock("@/hooks/use-relationship-app-state", () => ({ + useRelationshipAppState: vi.fn(), +})); + +import { RelationPropertyConfigurationDialog } from "./RelationPropertyConfigurationDialog"; + +const copy = messagesByLocale["en-US"].relationships; + +function setInputValue(input: HTMLInputElement, value: string) { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); +} + +describe("relation property configuration copy", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + document.body.replaceChildren(); + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = false; + }); + + it("keeps the shared bulk update status localized in Traditional Chinese", () => { + expect(messagesByLocale["zh-TW"].relationships.bulkUpdated).toBe( + "已更新 {{count}} 個關聯", + ); + }); + + it("uses the approved labels and collection terminology", () => { + act(() => { + root.render( + , + ); + }); + + expect(document.body.textContent).toContain("Column name"); + expect(document.body.textContent).toContain("Name shown on linked pages"); + expect(document.body.textContent).toContain( + "If shown on linked pages, this relationship is called Deliverables.", + ); + expect(document.body.textContent).toContain("Links per row"); + expect(document.body.textContent).toContain("Just one"); + expect(document.body.textContent).toContain("Multiple allowed"); + expect(document.body.textContent).toContain("Rows can also be left empty."); + expect(document.body.textContent).toContain("Link to pages in"); + expect( + document.body.querySelector("#relation-database-search") + ?.placeholder, + ).toBe("Search collections"); + expect(document.body.textContent).toContain( + "Also add a column in the linked collection", + ); + }); + + it("keeps both collection names in context while filtering the picker", () => { + act(() => { + root.render( + , + ); + }); + + const target = [...document.body.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Marketing Team"), + ); + act(() => target?.click()); + + const search = document.body.querySelector( + "#relation-database-search", + ); + act(() => { + if (search) setInputValue(search, "Marketing"); + }); + expect(document.body.textContent).toContain( + "Each row in Campaign Deliverables can link to multiple pages in Marketing Team.", + ); + expect(document.body.textContent).toContain( + "Also add a column in Marketing Team", + ); + + const justOne = document.body.querySelector( + 'input[name="relation-cardinality"][value="one"]', + ); + act(() => justOne?.click()); + expect(document.body.textContent).toContain( + "Each row in Campaign Deliverables can link to just one page in Marketing Team.", + ); + }); +}); diff --git a/templates/content/app/components/editor/RelationPropertyConfigurationDialog.tsx b/templates/content/app/components/editor/RelationPropertyConfigurationDialog.tsx index bad1425a5a4..bce27589d59 100644 --- a/templates/content/app/components/editor/RelationPropertyConfigurationDialog.tsx +++ b/templates/content/app/components/editor/RelationPropertyConfigurationDialog.tsx @@ -115,6 +115,12 @@ export function RelationPropertyConfigurationDialog({ ownerDatabaseId, ) : null; + const ownerDatabase = (databases.data?.databases ?? []).find( + (database) => database.databaseId === ownerDatabaseId, + ); + const targetDatabase = (databases.data?.databases ?? []).find( + (database) => database.databaseId === targetDatabaseId, + ); async function submit() { setError(null); @@ -262,6 +268,13 @@ export function RelationPropertyConfigurationDialog({ placeholder={t("relationships.inverseLabelPlaceholder")} onChange={(event) => setInverseLabel(event.target.value)} /> +

+ {t("relationships.inverseLabelHelper", { + name: + inverseLabel.trim() || + t("relationships.inverseLabelPlaceholder"), + })} +

@@ -292,6 +305,9 @@ export function RelationPropertyConfigurationDialog({ ))}
+

+ {t("relationships.emptyLinksHelper")} +

+ {ownerDatabase && targetDatabase ? ( +

+ {t( + forwardCardinality === "one" + ? "relationships.linkContextOne" + : "relationships.linkContextMany", + { + source: ownerDatabase.title, + target: targetDatabase.title, + }, + )} +

+ ) : null}
-
) : canEdit && itemProperty.editable ? ( { + it("adds a mixed candidate only to missing rows and preserves untouched people", () => { + const edges = [edge("launch", "mira"), edge("social", "jo")]; + const candidates = relationshipBulkCandidates({ + candidates: [candidate("mira"), candidate("jo")], + edges, + selectedPageIds: ["launch", "social"], + direction: "forward", + }); + expect( + candidates.map(({ pageId, selectionState }) => [pageId, selectionState]), + ).toEqual([ + ["mira", "mixed"], + ["jo", "mixed"], + ]); + + const intent = toggleRelationshipBulkIntent({ + candidateId: "mira", + candidates, + intent: {}, + direction: "forward", + forwardCardinality: "many", + }); + const plan = planRelationshipBulkChanges({ + candidates, + edges, + selectedPageIds: ["launch", "social"], + direction: "forward", + forwardCardinality: "many", + intent, + }); + expect(plan.error).toBeNull(); + expect(plan.items).toMatchObject([ + { + kind: "add", + anchorPageId: "social", + sourcePageId: "social", + targetPageId: "mira", + }, + ]); + }); + + it("collapses checked to off to checked into no net intent", () => { + const candidates = relationshipBulkCandidates({ + candidates: [candidate("mira")], + edges: [edge("launch", "mira"), edge("social", "mira")], + selectedPageIds: ["launch", "social"], + direction: "forward", + }); + const off = toggleRelationshipBulkIntent({ + candidateId: "mira", + candidates, + intent: {}, + direction: "forward", + forwardCardinality: "many", + }); + const restored = toggleRelationshipBulkIntent({ + candidateId: "mira", + candidates, + intent: off, + direction: "forward", + forwardCardinality: "many", + }); + expect(off).toEqual({ mira: false }); + expect(restored).toEqual({}); + expect( + planRelationshipBulkChanges({ + candidates, + edges: [edge("launch", "mira"), edge("social", "mira")], + selectedPageIds: ["launch", "social"], + direction: "forward", + forwardCardinality: "many", + intent: restored, + }).items, + ).toEqual([]); + }); + + it("rejects assigning one inverse max-one source to several selected rows", () => { + const candidates = relationshipBulkCandidates({ + candidates: [candidate("mira")], + edges: [], + selectedPageIds: ["launch", "social"], + direction: "inverse", + }); + expect( + planRelationshipBulkChanges({ + candidates, + edges: [], + selectedPageIds: ["launch", "social"], + direction: "inverse", + forwardCardinality: "one", + intent: { mira: true }, + }), + ).toEqual({ items: [], error: "inverse-max-one" }); + }); + + it("keeps inverse max-one sources independently selectable for one row", () => { + const candidates = relationshipBulkCandidates({ + candidates: [candidate("mira"), candidate("jo")], + edges: [], + selectedPageIds: ["launch"], + direction: "inverse", + }); + const mira = toggleRelationshipBulkIntent({ + candidateId: "mira", + candidates, + intent: {}, + direction: "inverse", + forwardCardinality: "one", + }); + const both = toggleRelationshipBulkIntent({ + candidateId: "jo", + candidates, + intent: mira, + direction: "inverse", + forwardCardinality: "one", + }); + expect(both).toEqual({ mira: true, jo: true }); + expect( + planRelationshipBulkChanges({ + candidates, + edges: [], + selectedPageIds: ["launch"], + direction: "inverse", + forwardCardinality: "one", + intent: both, + }).items, + ).toMatchObject([ + { kind: "replace", sourcePageId: "mira", targetPageId: "launch" }, + { kind: "replace", sourcePageId: "jo", targetPageId: "launch" }, + ]); + }); + + it("keeps one coherent final target when forward max-one choices change", () => { + const edges = [edge("launch", "mira"), edge("social", "mira")]; + const candidates = relationshipBulkCandidates({ + candidates: [candidate("mira"), candidate("jo"), candidate("sam")], + edges, + selectedPageIds: ["launch", "social"], + direction: "forward", + }); + const chooseJo = toggleRelationshipBulkIntent({ + candidateId: "jo", + candidates, + intent: {}, + direction: "forward", + forwardCardinality: "one", + }); + const chooseSam = toggleRelationshipBulkIntent({ + candidateId: "sam", + candidates, + intent: chooseJo, + direction: "forward", + forwardCardinality: "one", + }); + + expect(chooseSam).toEqual({ mira: false, sam: true }); + expect( + candidates.map((item) => + effectiveRelationshipBulkSelectionState(item, chooseSam), + ), + ).toEqual(["none", "none", "all"]); + expect( + planRelationshipBulkChanges({ + candidates, + edges, + selectedPageIds: ["launch", "social"], + direction: "forward", + forwardCardinality: "one", + intent: chooseSam, + }).items, + ).toMatchObject([ + { kind: "replace", anchorPageId: "launch", targetPageId: "sam" }, + { kind: "replace", anchorPageId: "social", targetPageId: "sam" }, + ]); + }); + + it("blocks errored and incomplete reads instead of exposing false unchecked states", () => { + expect( + relationshipBulkReadStatus([ + { isError: true }, + { isError: false, data: { nextCursor: null } }, + ]), + ).toBe("unavailable"); + expect( + relationshipBulkReadStatus([ + { isError: false, data: { nextCursor: "more" } }, + ]), + ).toBe("unavailable"); + expect(relationshipBulkReadStatus([{ isError: false }])).toBe("loading"); + expect( + relationshipBulkReadStatus([ + { + isError: false, + isPlaceholderData: true, + data: { nextCursor: null }, + }, + ]), + ).toBe("loading"); + }); +}); diff --git a/templates/content/app/components/editor/relationship-bulk.ts b/templates/content/app/components/editor/relationship-bulk.ts new file mode 100644 index 00000000000..e76bdf2faed --- /dev/null +++ b/templates/content/app/components/editor/relationship-bulk.ts @@ -0,0 +1,272 @@ +import type { + CanonicalRelationOptions, + ContentRelationCandidate, + ContentRelationshipItem, +} from "@shared/relationships"; + +export type RelationshipBulkSelectionState = "all" | "mixed" | "none"; + +export type RelationshipBulkCandidate = ContentRelationCandidate & { + selectionState: RelationshipBulkSelectionState; +}; + +export type RelationshipBulkIntent = Record; + +export type RelationshipBulkPlanItem = + | { + kind: "add"; + anchorPageId: string; + sourcePageId: string; + targetPageId: string; + } + | { + kind: "replace"; + anchorPageId: string; + sourcePageId: string; + targetPageId: string; + observedSlotToken: string; + } + | { + kind: "remove"; + anchorPageId: string; + edge: ContentRelationshipItem; + }; + +function edgeAnchorPageId( + edge: ContentRelationshipItem, + direction: CanonicalRelationOptions["direction"], +) { + return direction === "forward" ? edge.sourcePageId : edge.targetPageId; +} + +function edgeCandidatePageId( + edge: ContentRelationshipItem, + direction: CanonicalRelationOptions["direction"], +) { + return direction === "forward" ? edge.targetPageId : edge.sourcePageId; +} + +export function relationshipBulkCandidates({ + candidates, + edges, + selectedPageIds, + direction, +}: { + candidates: ContentRelationCandidate[]; + edges: ContentRelationshipItem[]; + selectedPageIds: string[]; + direction: CanonicalRelationOptions["direction"]; +}): RelationshipBulkCandidate[] { + const selectedSet = new Set(selectedPageIds); + const candidateByPageId = new Map( + candidates.map((candidate) => [candidate.pageId, candidate]), + ); + for (const edge of edges) { + const anchorPageId = edgeAnchorPageId(edge, direction); + if (!selectedSet.has(anchorPageId)) continue; + const endpoint = direction === "forward" ? edge.target : edge.source; + if (!candidateByPageId.has(endpoint.pageId)) { + candidateByPageId.set(endpoint.pageId, { + pageId: endpoint.pageId, + title: endpoint.title, + context: {}, + slotObservationToken: edge.slotObservationToken, + }); + } + } + + return [...candidateByPageId.values()].map((candidate) => { + const linkedAnchors = new Set( + edges + .filter( + (edge) => + edgeCandidatePageId(edge, direction) === candidate.pageId && + selectedSet.has(edgeAnchorPageId(edge, direction)), + ) + .map((edge) => edgeAnchorPageId(edge, direction)), + ); + return { + ...candidate, + selectionState: + linkedAnchors.size === 0 + ? "none" + : linkedAnchors.size === selectedSet.size + ? "all" + : "mixed", + }; + }); +} + +export function effectiveRelationshipBulkSelectionState( + candidate: RelationshipBulkCandidate, + intent: RelationshipBulkIntent, +): RelationshipBulkSelectionState { + const desired = intent[candidate.pageId]; + return desired === undefined + ? candidate.selectionState + : desired + ? "all" + : "none"; +} + +function setNormalizedIntent( + next: RelationshipBulkIntent, + candidate: RelationshipBulkCandidate, + desired: boolean, +) { + const originalMatches = desired + ? candidate.selectionState === "all" + : candidate.selectionState === "none"; + if (originalMatches) delete next[candidate.pageId]; + else next[candidate.pageId] = desired; +} + +export function toggleRelationshipBulkIntent({ + candidateId, + candidates, + intent, + direction, + forwardCardinality, +}: { + candidateId: string; + candidates: RelationshipBulkCandidate[]; + intent: RelationshipBulkIntent; + direction: CanonicalRelationOptions["direction"]; + forwardCardinality: "one" | "many"; +}): RelationshipBulkIntent { + const candidate = candidates.find((item) => item.pageId === candidateId); + if (!candidate) return intent; + const desired = + effectiveRelationshipBulkSelectionState(candidate, intent) !== "all"; + const next = { ...intent }; + + if (desired && direction === "forward" && forwardCardinality === "one") { + for (const item of candidates) { + setNormalizedIntent(next, item, item.pageId === candidateId); + } + } else { + setNormalizedIntent(next, candidate, desired); + } + return next; +} + +export function relationshipBulkReadStatus( + reads: Array<{ + isError: boolean; + isPlaceholderData?: boolean; + data?: { nextCursor: string | null }; + }>, +): "loading" | "unavailable" | "ready" { + if (reads.some((read) => read.isError || read.data?.nextCursor)) { + return "unavailable"; + } + return reads.some((read) => !read.data || read.isPlaceholderData) + ? "loading" + : "ready"; +} + +export function planRelationshipBulkChanges({ + candidates, + edges, + selectedPageIds, + direction, + forwardCardinality, + intent, +}: { + candidates: RelationshipBulkCandidate[]; + edges: ContentRelationshipItem[]; + selectedPageIds: string[]; + direction: CanonicalRelationOptions["direction"]; + forwardCardinality: "one" | "many"; + intent: RelationshipBulkIntent; +}): { + items: RelationshipBulkPlanItem[]; + error: "inverse-max-one" | "refresh-required" | null; +} { + const intendedEntries = Object.entries(intent); + if ( + direction === "inverse" && + forwardCardinality === "one" && + selectedPageIds.length > 1 && + intendedEntries.some(([, desired]) => desired) + ) { + return { items: [], error: "inverse-max-one" }; + } + + const candidatesById = new Map( + candidates.map((candidate) => [candidate.pageId, candidate]), + ); + const desiredAllIds = new Set( + intendedEntries.flatMap(([candidateId, desired]) => + desired ? [candidateId] : [], + ), + ); + const items: RelationshipBulkPlanItem[] = []; + + for (const anchorPageId of selectedPageIds) { + const currentEdges = edges.filter( + (edge) => edgeAnchorPageId(edge, direction) === anchorPageId, + ); + const replacementCandidateId = + direction === "forward" && forwardCardinality === "one" + ? [...desiredAllIds][0] + : undefined; + const replacementNeeded = + replacementCandidateId !== undefined && + !currentEdges.some( + (edge) => + edgeCandidatePageId(edge, direction) === replacementCandidateId, + ); + + for (const [candidateId, desired] of intendedEntries) { + const matchingEdge = currentEdges.find( + (edge) => edgeCandidatePageId(edge, direction) === candidateId, + ); + if (desired) { + if (matchingEdge) continue; + const sourcePageId = + direction === "forward" ? anchorPageId : candidateId; + const targetPageId = + direction === "forward" ? candidateId : anchorPageId; + if (forwardCardinality === "one") { + const observedSlotToken = + direction === "inverse" + ? candidatesById.get(candidateId)?.slotObservationToken + : currentEdges[0]?.slotObservationToken; + if (direction === "inverse" || currentEdges.length > 0) { + if (!observedSlotToken) { + return { items: [], error: "refresh-required" }; + } + items.push({ + kind: "replace", + anchorPageId, + sourcePageId, + targetPageId, + observedSlotToken, + }); + continue; + } + } + items.push({ + kind: "add", + anchorPageId, + sourcePageId, + targetPageId, + }); + continue; + } + if (!matchingEdge) continue; + if ( + direction === "forward" && + replacementNeeded && + forwardCardinality === "one" && + edgeCandidatePageId(matchingEdge, direction) !== replacementCandidateId + ) { + continue; + } + items.push({ kind: "remove", anchorPageId, edge: matchingEdge }); + } + } + + return { items, error: null }; +} diff --git a/templates/content/app/hooks/use-relationship-app-state.ts b/templates/content/app/hooks/use-relationship-app-state.ts index 47f2b7a59c6..9863b784900 100644 --- a/templates/content/app/hooks/use-relationship-app-state.ts +++ b/templates/content/app/hooks/use-relationship-app-state.ts @@ -14,7 +14,7 @@ export interface ContentRelationshipContext { typeId?: string; databaseId?: string; selectedPageIds?: string[]; - surface: "picker" | "connections" | "bulk" | "history" | "configuration"; + surface: "picker" | "bulk" | "history" | "configuration"; } interface RelationshipContextOwner { diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 58a1f691fdf..ec216544daa 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -3140,18 +3140,26 @@ const relationshipMessages = { definition: "Relationship definition", newRelationship: "New relationship", existingRelationship: "Existing relationship", - forwardLabel: "Property name", + forwardLabel: "Column name", forwardLabelPlaceholder: "Contributors", - inverseLabel: "Inverse name", + inverseLabel: "Name shown on linked pages", inverseLabelPlaceholder: "Deliverables", - cardinality: "Pages per row", - onePage: "One page", - manyPages: "Many pages", - targetDatabase: "Target database", - searchDatabases: "Search databases", - retryDatabases: "Retry databases", - noDatabases: "No matching databases", - createInverseProperty: "Create inverse property", + inverseLabelHelper: + "If shown on linked pages, this relationship is called {{name}}.", + cardinality: "Links per row", + onePage: "Just one", + manyPages: "Multiple allowed", + emptyLinksHelper: "Rows can also be left empty.", + linkContextOne: + "Each row in {{source}} can link to just one page in {{target}}.", + linkContextMany: + "Each row in {{source}} can link to multiple pages in {{target}}.", + targetDatabase: "Link to pages in", + searchDatabases: "Search collections", + retryDatabases: "Retry collections", + noDatabases: "No matching collections", + createInverseProperty: "Also add a column in {{name}}", + createInversePropertyUnselected: "Also add a column in the linked collection", inverseEditable: "Allow editing from the inverse property", retryRelationships: "Retry relationships", noExistingRelationships: "No existing relationships", @@ -3206,6 +3214,7 @@ const relationshipMessages = { apply: "Apply", bulkAdded: "Added relationships to {{count}} rows", bulkRemoved: "Removed relationships from {{count}} rows", + bulkUpdated: "Updated {{count}} relationships", bulkFailed: "No relationships changed.", bulkInverseMaxOne: "A one-page relationship cannot assign one source page to multiple selected rows.", @@ -3715,15 +3724,20 @@ const relationshipMessagesByLocale = { definition: "关联定义", newRelationship: "新建关联", existingRelationship: "现有关联", - forwardLabel: "属性名称", - inverseLabel: "反向名称", - cardinality: "每行页面数", - onePage: "一个页面", - manyPages: "多个页面", - targetDatabase: "目标数据库", - searchDatabases: "搜索数据库", - noDatabases: "没有匹配的数据库", - createInverseProperty: "创建反向属性", + forwardLabel: "列名称", + inverseLabel: "在链接页面上显示的名称", + inverseLabelHelper: "如果显示在链接页面上,此关系称为 {{name}}。", + cardinality: "每行链接数", + onePage: "仅一个", + manyPages: "允许多个", + emptyLinksHelper: "行也可以留空。", + linkContextOne: "{{source}} 中的每行只能链接到 {{target}} 中的一个页面。", + linkContextMany: "{{source}} 中的每行可以链接到 {{target}} 中的多个页面。", + targetDatabase: "链接到以下集合中的页面", + searchDatabases: "搜索集合", + noDatabases: "没有匹配的集合", + createInverseProperty: "同时在 {{name}} 中添加一列", + createInversePropertyUnselected: "同时在链接的集合中添加一列", inverseEditable: "允许从反向属性编辑", searchPages: "搜索页面", noMatchingPages: "没有匹配的页面", @@ -3742,7 +3756,7 @@ const relationshipMessagesByLocale = { selectRelationshipsToRemove: "同时移除所选关联", removePropertyOnly: "仅移除属性", removePropertyAndSelected: "移除属性和 {{count}} 个所选关联", - retryDatabases: "重试加载数据库", + retryDatabases: "重试加载集合", retryRelationships: "重试加载关联", noExistingRelationships: "没有现有关联", configurationFailed: "无法添加关联。", @@ -3779,15 +3793,20 @@ const relationshipMessagesByLocale = { definition: "關聯定義", newRelationship: "新關聯", existingRelationship: "現有關聯", - forwardLabel: "屬性名稱", - inverseLabel: "反向名稱", - cardinality: "每列頁面數", - onePage: "一個頁面", - manyPages: "多個頁面", - targetDatabase: "目標資料庫", - searchDatabases: "搜尋資料庫", - noDatabases: "沒有相符的資料庫", - createInverseProperty: "建立反向屬性", + forwardLabel: "欄名稱", + inverseLabel: "在連結頁面上顯示的名稱", + inverseLabelHelper: "如果顯示在連結頁面上,此關係稱為 {{name}}。", + cardinality: "每列連結數", + onePage: "僅一個", + manyPages: "允許多個", + emptyLinksHelper: "列也可以留空。", + linkContextOne: "{{source}} 中的每列只能連結到 {{target}} 中的一個頁面。", + linkContextMany: "{{source}} 中的每列可以連結到 {{target}} 中的多個頁面。", + targetDatabase: "連結到以下集合中的頁面", + searchDatabases: "搜尋集合", + noDatabases: "沒有相符的集合", + createInverseProperty: "同時在 {{name}} 中新增一欄", + createInversePropertyUnselected: "同時在連結的集合中新增一欄", inverseEditable: "允許從反向屬性編輯", searchPages: "搜尋頁面", noMatchingPages: "沒有相符的頁面", @@ -3815,6 +3834,7 @@ const relationshipMessagesByLocale = { apply: "套用", addToSelected: "新增", removeFromSelected: "移除", + bulkUpdated: "已更新 {{count}} 個關聯", removeProperty: "移除關聯屬性", relationshipsPreserved: "預設會保留關聯", selectRelationshipsToRemove: "同時移除所選關聯", @@ -3829,15 +3849,24 @@ const relationshipMessagesByLocale = { definition: "Definición de la relación", newRelationship: "Nueva relación", existingRelationship: "Relación existente", - forwardLabel: "Nombre de la propiedad", - inverseLabel: "Nombre inverso", - cardinality: "Páginas por fila", - onePage: "Una página", - manyPages: "Varias páginas", - targetDatabase: "Base de datos de destino", - searchDatabases: "Buscar bases de datos", - noDatabases: "No hay bases de datos coincidentes", - createInverseProperty: "Crear propiedad inversa", + forwardLabel: "Nombre de la columna", + inverseLabel: "Nombre mostrado en las páginas vinculadas", + inverseLabelHelper: + "Si se muestra en las páginas vinculadas, esta relación se llama {{name}}.", + cardinality: "Enlaces por fila", + onePage: "Solo uno", + manyPages: "Se permiten varios", + emptyLinksHelper: "Las filas también pueden dejarse vacías.", + linkContextOne: + "Cada fila de {{source}} puede vincularse con una sola página de {{target}}.", + linkContextMany: + "Cada fila de {{source}} puede vincularse con varias páginas de {{target}}.", + targetDatabase: "Vincular a páginas de", + searchDatabases: "Buscar colecciones", + noDatabases: "No hay colecciones coincidentes", + createInverseProperty: "Añadir también una columna en {{name}}", + createInversePropertyUnselected: + "Añadir también una columna en la colección vinculada", inverseEditable: "Permitir editar desde la propiedad inversa", searchPages: "Buscar páginas", noMatchingPages: "No hay páginas coincidentes", @@ -3857,7 +3886,7 @@ const relationshipMessagesByLocale = { selectRelationshipsToRemove: "Quitar también las relaciones seleccionadas", removePropertyOnly: "Quitar propiedad", removePropertyAndSelected: "Quitar propiedad y {{count}} seleccionadas", - retryDatabases: "Reintentar bases de datos", + retryDatabases: "Reintentar colecciones", retryRelationships: "Reintentar relaciones", noExistingRelationships: "No hay relaciones existentes", configurationFailed: "No se pudo añadir la relación.", @@ -3898,15 +3927,24 @@ const relationshipMessagesByLocale = { definition: "Définition de la relation", newRelationship: "Nouvelle relation", existingRelationship: "Relation existante", - forwardLabel: "Nom de la propriété", - inverseLabel: "Nom inverse", - cardinality: "Pages par ligne", - onePage: "Une page", - manyPages: "Plusieurs pages", - targetDatabase: "Base de données cible", - searchDatabases: "Rechercher des bases de données", - noDatabases: "Aucune base de données correspondante", - createInverseProperty: "Créer la propriété inverse", + forwardLabel: "Nom de la colonne", + inverseLabel: "Nom affiché sur les pages liées", + inverseLabelHelper: + "Si elle apparaît sur les pages liées, cette relation s’appelle {{name}}.", + cardinality: "Liens par ligne", + onePage: "Un seul", + manyPages: "Plusieurs autorisés", + emptyLinksHelper: "Les lignes peuvent aussi rester vides.", + linkContextOne: + "Chaque ligne de {{source}} peut être liée à une seule page de {{target}}.", + linkContextMany: + "Chaque ligne de {{source}} peut être liée à plusieurs pages de {{target}}.", + targetDatabase: "Lier aux pages de", + searchDatabases: "Rechercher des collections", + noDatabases: "Aucune collection correspondante", + createInverseProperty: "Ajouter aussi une colonne dans {{name}}", + createInversePropertyUnselected: + "Ajouter aussi une colonne dans la collection liée", inverseEditable: "Autoriser la modification depuis la propriété inverse", searchPages: "Rechercher des pages", noMatchingPages: "Aucune page correspondante", @@ -3926,7 +3964,7 @@ const relationshipMessagesByLocale = { removePropertyOnly: "Supprimer la propriété", removePropertyAndSelected: "Supprimer la propriété et {{count}} sélectionnées", - retryDatabases: "Réessayer les bases de données", + retryDatabases: "Réessayer les collections", retryRelationships: "Réessayer les relations", noExistingRelationships: "Aucune relation existante", configurationFailed: "Impossible d’ajouter la relation.", @@ -3969,15 +4007,24 @@ const relationshipMessagesByLocale = { definition: "Beziehungsdefinition", newRelationship: "Neue Beziehung", existingRelationship: "Bestehende Beziehung", - forwardLabel: "Eigenschaftsname", - inverseLabel: "Umgekehrter Name", - cardinality: "Seiten pro Zeile", - onePage: "Eine Seite", - manyPages: "Mehrere Seiten", - targetDatabase: "Zieldatenbank", - searchDatabases: "Datenbanken suchen", - noDatabases: "Keine passenden Datenbanken", - createInverseProperty: "Umgekehrte Eigenschaft erstellen", + forwardLabel: "Spaltenname", + inverseLabel: "Auf verknüpften Seiten angezeigter Name", + inverseLabelHelper: + "Wenn diese Beziehung auf verknüpften Seiten angezeigt wird, heißt sie {{name}}.", + cardinality: "Links pro Zeile", + onePage: "Nur eine", + manyPages: "Mehrere erlaubt", + emptyLinksHelper: "Zeilen können auch leer bleiben.", + linkContextOne: + "Jede Zeile in {{source}} kann mit nur einer Seite in {{target}} verknüpft werden.", + linkContextMany: + "Jede Zeile in {{source}} kann mit mehreren Seiten in {{target}} verknüpft werden.", + targetDatabase: "Mit Seiten verknüpfen in", + searchDatabases: "Sammlungen durchsuchen", + noDatabases: "Keine passenden Sammlungen", + createInverseProperty: "Auch eine Spalte in {{name}} hinzufügen", + createInversePropertyUnselected: + "Auch eine Spalte in der verknüpften Sammlung hinzufügen", inverseEditable: "Bearbeitung über die umgekehrte Eigenschaft erlauben", searchPages: "Seiten suchen", noMatchingPages: "Keine passenden Seiten", @@ -3997,7 +4044,7 @@ const relationshipMessagesByLocale = { removePropertyOnly: "Eigenschaft entfernen", removePropertyAndSelected: "Eigenschaft und {{count}} ausgewählte entfernen", - retryDatabases: "Datenbanken erneut laden", + retryDatabases: "Sammlungen erneut laden", retryRelationships: "Beziehungen erneut laden", noExistingRelationships: "Keine bestehenden Beziehungen", configurationFailed: "Die Beziehung konnte nicht hinzugefügt werden.", @@ -4040,15 +4087,23 @@ const relationshipMessagesByLocale = { definition: "リレーション定義", newRelationship: "新しいリレーション", existingRelationship: "既存のリレーション", - forwardLabel: "プロパティ名", - inverseLabel: "逆方向の名前", - cardinality: "行ごとのページ数", - onePage: "1 ページ", - manyPages: "複数ページ", - targetDatabase: "対象データベース", - searchDatabases: "データベースを検索", - noDatabases: "一致するデータベースはありません", - createInverseProperty: "逆方向プロパティを作成", + forwardLabel: "列名", + inverseLabel: "リンク先のページに表示する名前", + inverseLabelHelper: + "リンク先のページに表示される場合、このリレーションは {{name}} と呼ばれます。", + cardinality: "行ごとのリンク数", + onePage: "1 つのみ", + manyPages: "複数可", + emptyLinksHelper: "行を空のままにすることもできます。", + linkContextOne: + "{{source}} の各行は、{{target}} の 1 ページだけにリンクできます。", + linkContextMany: + "{{source}} の各行は、{{target}} の複数のページにリンクできます。", + targetDatabase: "ページのリンク先", + searchDatabases: "コレクションを検索", + noDatabases: "一致するコレクションはありません", + createInverseProperty: "{{name}} にも列を追加", + createInversePropertyUnselected: "リンク先のコレクションにも列を追加", inverseEditable: "逆方向プロパティからの編集を許可", searchPages: "ページを検索", noMatchingPages: "一致するページはありません", @@ -4067,7 +4122,7 @@ const relationshipMessagesByLocale = { selectRelationshipsToRemove: "選択したリレーションも削除", removePropertyOnly: "プロパティを削除", removePropertyAndSelected: "プロパティと選択した {{count}} 件を削除", - retryDatabases: "データベースを再読み込み", + retryDatabases: "コレクションを再読み込み", retryRelationships: "リレーションを再読み込み", noExistingRelationships: "既存のリレーションはありません", configurationFailed: "リレーションを追加できませんでした。", @@ -4108,15 +4163,23 @@ const relationshipMessagesByLocale = { definition: "관계 정의", newRelationship: "새 관계", existingRelationship: "기존 관계", - forwardLabel: "속성 이름", - inverseLabel: "역방향 이름", - cardinality: "행당 페이지 수", - onePage: "페이지 하나", - manyPages: "여러 페이지", - targetDatabase: "대상 데이터베이스", - searchDatabases: "데이터베이스 검색", - noDatabases: "일치하는 데이터베이스가 없습니다", - createInverseProperty: "역방향 속성 만들기", + forwardLabel: "열 이름", + inverseLabel: "연결된 페이지에 표시되는 이름", + inverseLabelHelper: + "연결된 페이지에 표시되는 경우 이 관계의 이름은 {{name}}입니다.", + cardinality: "행당 링크 수", + onePage: "하나만", + manyPages: "여러 개 허용", + emptyLinksHelper: "행을 비워 둘 수도 있습니다.", + linkContextOne: + "{{source}}의 각 행은 {{target}}의 페이지 하나에만 연결할 수 있습니다.", + linkContextMany: + "{{source}}의 각 행은 {{target}}의 여러 페이지에 연결할 수 있습니다.", + targetDatabase: "페이지를 연결할 컬렉션", + searchDatabases: "컬렉션 검색", + noDatabases: "일치하는 컬렉션이 없습니다", + createInverseProperty: "{{name}}에도 열 추가", + createInversePropertyUnselected: "연결된 컬렉션에도 열 추가", inverseEditable: "역방향 속성에서 편집 허용", searchPages: "페이지 검색", noMatchingPages: "일치하는 페이지가 없습니다", @@ -4135,7 +4198,7 @@ const relationshipMessagesByLocale = { selectRelationshipsToRemove: "선택한 관계도 제거", removePropertyOnly: "속성 제거", removePropertyAndSelected: "속성 및 선택한 {{count}}개 제거", - retryDatabases: "데이터베이스 다시 불러오기", + retryDatabases: "컬렉션 다시 불러오기", retryRelationships: "관계 다시 불러오기", noExistingRelationships: "기존 관계 없음", configurationFailed: "관계를 추가하지 못했습니다.", @@ -4175,15 +4238,24 @@ const relationshipMessagesByLocale = { definition: "Definição da relação", newRelationship: "Nova relação", existingRelationship: "Relação existente", - forwardLabel: "Nome da propriedade", - inverseLabel: "Nome inverso", - cardinality: "Páginas por linha", - onePage: "Uma página", - manyPages: "Várias páginas", - targetDatabase: "Banco de dados de destino", - searchDatabases: "Pesquisar bancos de dados", - noDatabases: "Nenhum banco de dados correspondente", - createInverseProperty: "Criar propriedade inversa", + forwardLabel: "Nome da coluna", + inverseLabel: "Nome exibido nas páginas vinculadas", + inverseLabelHelper: + "Se aparecer nas páginas vinculadas, esta relação se chama {{name}}.", + cardinality: "Links por linha", + onePage: "Apenas um", + manyPages: "Vários permitidos", + emptyLinksHelper: "As linhas também podem ficar vazias.", + linkContextOne: + "Cada linha em {{source}} pode ser vinculada a apenas uma página em {{target}}.", + linkContextMany: + "Cada linha em {{source}} pode ser vinculada a várias páginas em {{target}}.", + targetDatabase: "Vincular a páginas em", + searchDatabases: "Pesquisar coleções", + noDatabases: "Nenhuma coleção correspondente", + createInverseProperty: "Adicionar também uma coluna em {{name}}", + createInversePropertyUnselected: + "Adicionar também uma coluna na coleção vinculada", inverseEditable: "Permitir edição pela propriedade inversa", searchPages: "Pesquisar páginas", noMatchingPages: "Nenhuma página correspondente", @@ -4202,7 +4274,7 @@ const relationshipMessagesByLocale = { selectRelationshipsToRemove: "Remover também as relações selecionadas", removePropertyOnly: "Remover propriedade", removePropertyAndSelected: "Remover propriedade e {{count}} selecionadas", - retryDatabases: "Tentar carregar bancos de dados novamente", + retryDatabases: "Tentar carregar coleções novamente", retryRelationships: "Tentar carregar relações novamente", noExistingRelationships: "Nenhuma relação existente", configurationFailed: "Não foi possível adicionar a relação.", @@ -4244,15 +4316,23 @@ const relationshipMessagesByLocale = { definition: "रिलेशन की परिभाषा", newRelationship: "नया रिलेशन", existingRelationship: "मौजूदा रिलेशन", - forwardLabel: "प्रॉपर्टी का नाम", - inverseLabel: "उलटा नाम", - cardinality: "हर पंक्ति में पेज", - onePage: "एक पेज", - manyPages: "कई पेज", - targetDatabase: "लक्षित डेटाबेस", - searchDatabases: "डेटाबेस खोजें", - noDatabases: "कोई मिलता हुआ डेटाबेस नहीं", - createInverseProperty: "उलटी प्रॉपर्टी बनाएँ", + forwardLabel: "कॉलम का नाम", + inverseLabel: "लिंक किए गए पेजों पर दिखाया गया नाम", + inverseLabelHelper: + "लिंक किए गए पेजों पर दिखने पर इस रिलेशन को {{name}} कहा जाता है।", + cardinality: "हर पंक्ति में लिंक", + onePage: "सिर्फ़ एक", + manyPages: "एक से अधिक की अनुमति", + emptyLinksHelper: "पंक्तियाँ खाली भी छोड़ी जा सकती हैं।", + linkContextOne: + "{{source}} की हर पंक्ति {{target}} के सिर्फ़ एक पेज से लिंक हो सकती है।", + linkContextMany: + "{{source}} की हर पंक्ति {{target}} के कई पेजों से लिंक हो सकती है।", + targetDatabase: "इनमें मौजूद पेजों से लिंक करें", + searchDatabases: "कलेक्शन खोजें", + noDatabases: "कोई मिलता हुआ कलेक्शन नहीं", + createInverseProperty: "{{name}} में भी एक कॉलम जोड़ें", + createInversePropertyUnselected: "लिंक किए गए कलेक्शन में भी एक कॉलम जोड़ें", inverseEditable: "उलटी प्रॉपर्टी से संपादन की अनुमति दें", searchPages: "पेज खोजें", noMatchingPages: "कोई मिलता हुआ पेज नहीं", @@ -4271,7 +4351,7 @@ const relationshipMessagesByLocale = { selectRelationshipsToRemove: "चुने हुए रिलेशन भी हटाएँ", removePropertyOnly: "प्रॉपर्टी हटाएँ", removePropertyAndSelected: "प्रॉपर्टी और चुने हुए {{count}} हटाएँ", - retryDatabases: "डेटाबेस फिर लोड करें", + retryDatabases: "कलेक्शन फिर लोड करें", retryRelationships: "रिलेशन फिर लोड करें", noExistingRelationships: "कोई मौजूदा रिलेशन नहीं", configurationFailed: "रिलेशन नहीं जोड़ा जा सका।", @@ -4311,15 +4391,22 @@ const relationshipMessagesByLocale = { definition: "تعريف العلاقة", newRelationship: "علاقة جديدة", existingRelationship: "علاقة موجودة", - forwardLabel: "اسم الخاصية", - inverseLabel: "الاسم العكسي", - cardinality: "الصفحات لكل صف", - onePage: "صفحة واحدة", - manyPages: "صفحات متعددة", - targetDatabase: "قاعدة البيانات المستهدفة", - searchDatabases: "البحث في قواعد البيانات", - noDatabases: "لا توجد قواعد بيانات مطابقة", - createInverseProperty: "إنشاء خاصية عكسية", + forwardLabel: "اسم العمود", + inverseLabel: "الاسم الظاهر على الصفحات المرتبطة", + inverseLabelHelper: + "إذا ظهرت هذه العلاقة على الصفحات المرتبطة، فسيكون اسمها {{name}}.", + cardinality: "الروابط لكل صف", + onePage: "واحدة فقط", + manyPages: "يُسمح بعدة صفحات", + emptyLinksHelper: "يمكن أيضًا ترك الصفوف فارغة.", + linkContextOne: + "يمكن ربط كل صف في {{source}} بصفحة واحدة فقط في {{target}}.", + linkContextMany: "يمكن ربط كل صف في {{source}} بعدة صفحات في {{target}}.", + targetDatabase: "الربط بصفحات في", + searchDatabases: "البحث في المجموعات", + noDatabases: "لا توجد مجموعات مطابقة", + createInverseProperty: "إضافة عمود أيضًا في {{name}}", + createInversePropertyUnselected: "إضافة عمود أيضًا في المجموعة المرتبطة", inverseEditable: "السماح بالتحرير من الخاصية العكسية", searchPages: "البحث في الصفحات", noMatchingPages: "لا توجد صفحات مطابقة", @@ -4338,7 +4425,7 @@ const relationshipMessagesByLocale = { selectRelationshipsToRemove: "إزالة العلاقات المحددة أيضًا", removePropertyOnly: "إزالة الخاصية", removePropertyAndSelected: "إزالة الخاصية و{{count}} من المحدد", - retryDatabases: "إعادة تحميل قواعد البيانات", + retryDatabases: "إعادة تحميل المجموعات", retryRelationships: "إعادة تحميل العلاقات", noExistingRelationships: "لا توجد علاقات حالية", configurationFailed: "تعذرت إضافة العلاقة.", diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index 91257158e3f..fab2c144e39 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -39,18 +39,23 @@ const messages = { definition: "關聯定義", newRelationship: "新關聯", existingRelationship: "現有關聯", - forwardLabel: "屬性名稱", + forwardLabel: "欄名稱", forwardLabelPlaceholder: "貢獻者", - inverseLabel: "反向名稱", + inverseLabel: "在連結頁面上顯示的名稱", inverseLabelPlaceholder: "交付項目", - cardinality: "每列頁面數", - onePage: "一個頁面", - manyPages: "多個頁面", - targetDatabase: "目標資料庫", - searchDatabases: "搜尋資料庫", - retryDatabases: "重新載入資料庫", - noDatabases: "沒有相符的資料庫", - createInverseProperty: "建立反向屬性", + inverseLabelHelper: "如果顯示在連結頁面上,此關係稱為 {{name}}。", + cardinality: "每列連結數", + onePage: "僅一個", + manyPages: "允許多個", + emptyLinksHelper: "列也可以留空。", + linkContextOne: "{{source}} 中的每列只能連結到 {{target}} 中的一個頁面。", + linkContextMany: "{{source}} 中的每列可以連結到 {{target}} 中的多個頁面。", + targetDatabase: "連結到以下集合中的頁面", + searchDatabases: "搜尋集合", + retryDatabases: "重新載入集合", + noDatabases: "沒有相符的集合", + createInverseProperty: "同時在 {{name}} 中新增一欄", + createInversePropertyUnselected: "同時在連結的集合中新增一欄", inverseEditable: "允許從反向屬性編輯", retryRelationships: "重新載入關聯", noExistingRelationships: "沒有現有關聯", @@ -104,6 +109,7 @@ const messages = { apply: "套用", bulkAdded: "已將關聯新增至 {{count}} 列", bulkRemoved: "已從 {{count}} 列移除關聯", + bulkUpdated: "已更新 {{count}} 個關聯", bulkFailed: "未變更任何關聯。", bulkInverseMaxOne: "單頁關聯無法將一個來源頁面指派給多個所選資料列。", removeProperty: "移除關聯屬性", diff --git a/templates/content/changelog/2026-09-08-typed-page-relationships.md b/templates/content/changelog/2026-09-08-typed-page-relationships.md index eed9075e2c2..ed192743e41 100644 --- a/templates/content/changelog/2026-09-08-typed-page-relationships.md +++ b/templates/content/changelog/2026-09-08-typed-page-relationships.md @@ -3,4 +3,4 @@ type: added date: 2026-09-08 --- -Connect pages with Relation Properties, manage assignments from either direction, and recover relationship changes from history. +Connect pages with clickable Relation columns, optionally show a reverse column, bulk-edit selected rows atomically, and recover changes from History. diff --git a/templates/content/docs/product/capabilities/content.relationship.edge.md b/templates/content/docs/product/capabilities/content.relationship.edge.md index f68ae0cfe8b..3c7c8d2b3d7 100644 --- a/templates/content/docs/product/capabilities/content.relationship.edge.md +++ b/templates/content/docs/product/capabilities/content.relationship.edge.md @@ -48,7 +48,7 @@ Content instead stores one canonical typed Relationship between stable Page iden ## Example workflow -A team has a Tasks Database and a Projects Database. Adding a `Project` Relation Property to Tasks creates a local Relationship type and its first visible projection. A task editor connects a task to a project from the cell. The project immediately shows the inverse connection in **Info → Connections**, and an optional inverse Relation Property can expose the same edge as an editable `Tasks` column. +A team has a Tasks Database and a Projects Database. Adding a `Project` Relation Property to Tasks creates a local Relationship type and its first visible projection. A task editor connects a task to a project from the clickable field. If the editor also creates the optional reverse Relation Property, the project shows the same edge as a `Tasks` field; without that reverse column, the current slice does not display the relationship on the project side. Later, the team selects several tasks and assigns the same project in one bulk edit. Removing the `Project` column does not erase those relationships. If the team truly wants to remove both the column and its knowledge, the removal dialog offers **Remove Property and its N relationships**, reports the exact authorized impact, commits one Revision, and supports Undo. @@ -66,7 +66,9 @@ Later, the team selects several tasks and assigns the same project in one bulk e ### Where people manage relationships - Adding an ordinary Relation column creates a local Relationship type and its first Property projection. It feels like adding any other column; no catalog ceremony is required. -- **Info → Connections** is the universal relationship surface. It shows every accessible incoming and outgoing connection, including types not exposed as columns. +- In the current approved slice, clickable Relation fields in tables and Page Info are the relationship UI. An optional reverse column exposes the same edge on the target side. There is no separate **Connections** or **Other connections** list, and removing the last applicable column leaves preserved edges out of the ordinary Page UI. +- Authorized Actions can still inspect preserved edges when no column displays them. History remains independently available so an authorized person can recover a removed projection. Absence of a column is a presentation choice, not proof that an edge is absent or inaccessible. +- A universal **Info → Connections** neighborhood remains a possible broader-roadmap projection alongside inline references, Graph, and Canvas. It is not part of the current slice and must not be presented as current behavior. - A Relation picker may use a Database or Query to narrow candidates, but the stored endpoint is the selected Page's stable ID. The edge remains if that Page later leaves the picker Query. - Ordinary Page mentions and transclusions create system-managed structural edges with their own mutation rules. An advanced inline action may create a semantic typed reference. Removing one anchored mention does not delete an independently asserted semantic Relationship. - In freeform Canvas mode, a drawn connector is view-local brainstorming state until someone explicitly promotes it to a Relationship type. In semantic Graph mode, the active edge tool requires a Relationship type before drawing commits an edge. @@ -74,7 +76,7 @@ Later, the team selects several tasks and assigns the same project in one bulk e ### Local and governed definitions -- A local Relationship type belongs to the Content space where it was created and remains resolvable in Connections after its last visible Property is removed. +- A local Relationship type belongs to the Content space where it was created and remains resolvable through authorized Actions after its last visible Property is removed. A broader-roadmap Connections projection may expose it later. - **Save as Custom Property** promotes the definition into the governed Custom Properties catalog at an allowed Personal, Workspace, or Organization scope. - Another Database may adopt the governed Property, creating another projection of the same Relationship type rather than copying its semantic identity. - Each projection may choose a local display alias, formatting, renderer, and visibility without changing the shared definition. @@ -92,14 +94,14 @@ Later, the team selects several tasks and assigns the same project in one bulk e - A directional type permits at most one live edge for the same type, source, and target. Adding it again is idempotent. Symmetric types treat `(A, B)` and `(B, A)` as the same pair. - Different Relationship types remain independent, and reversed directional edges may coexist. Cycle rules are explicit validation on types or workflows, not deduplication and not a universal ban. - Self-relationships are disabled by default and can be enabled as an advanced Relationship-type option. -- Repeated anchored citations or mentions retain their own occurrences and history while Connections may summarize them as one Page-to-Page relationship. +- Repeated anchored citations or mentions retain their own occurrences and history. A future neighborhood projection may summarize them as one Page-to-Page relationship without becoming another source of truth. - A connection that needs several independent instances with dates, roles, or state becomes an intermediate Page with Properties rather than several indistinguishable parallel edges. ## Permissions and authority - Editing a forward Relation Property requires **Can edit entries** in the Database owning that projection, access to the target Page, permission to use the Relationship type, and satisfaction of its constraints. -- An explicitly editable inverse Relation Property grants the matching inverse-side editing route. An inverse shown only in Connections remains read-only from that side. -- On an ordinary Page, **Can edit** permits outgoing directional Relationship changes through Connections. Incoming directional edges remain read-only unless the actor also has an authorized source-side or editable-inverse route. +- An explicitly editable inverse Relation Property grants the matching inverse-side editing route. Merely reading an incoming edge through an Action grants no mutation authority. +- On an ordinary Page, **Can edit** permits outgoing directional Relationship changes through the authorized Action route. Incoming directional edges remain read-only unless the actor also has an authorized source-side or editable-inverse route. Any future neighborhood UI must preserve the same decision. - A symmetric Relationship may be changed through an authorized edit route on either endpoint. - Seeing an endpoint or an incoming edge never grants authority to sever it. Owning a Relationship type does not grant access to every private edge that uses it. - Same-Organization cross-Workspace edges may be allowed by policy. Cross-Organization canonical edges are prohibited by default until federation can preserve both organizations' access, deletion, and governance guarantees. @@ -109,6 +111,7 @@ Later, the team selects several tasks and assigns the same project in one bulk e ## Bulk operations, deletion, and concurrency - Multi-cell selection, row bulk edit, paste/fill, and filtered selection may add Relationships. Delete/Backspace, **Clear relationships**, target removal, and filtered bulk editing remove the exact selected edges. +- The current bulk editor uses one clickable list with three states: checked means linked from every selected row, mixed means linked from some, and unchecked means linked from none. Apply submits the net toggles as one atomic mutation, preserves untouched assignments, and makes no change for a toggle returned to its original state. An unreadable or incomplete observation never appears as unchecked. - Bulk mutation preflights the complete selection. Ambiguous input or mixed permissions never produces a silent partial commit. Content identifies conflicts or locked items and lets the person narrow explicitly; an agent may narrow only when that remains faithful to the request and must report skipped scope. - A filtered bulk removal resolves exact edge IDs and activation states when the selection is made. New concurrent matches are not swept in later. - Removing a Relation Property preserves its Relationships by default. The destructive dialog offers **Remove Property** or **Remove Property and its N relationships**, with an access-scoped impact count, one attributable Revision, and Undo. **Manage relationships** opens a filterable collection before removal. @@ -140,11 +143,11 @@ Later, the team selects several tasks and assigns the same project in one bulk e ### Create once and edit from either direction -Given two authorized Pages and a directional Relationship type with forward and inverse projections, when an editor creates the edge from the forward Relation Property and later removes it through the editable inverse projection, then Info, both Properties, Queries, Graph, and the shared Action surface show one canonical edge and one coherent history. +Given two authorized Pages and a directional Relationship type with forward and editable inverse projections, when an editor creates the edge from the forward Relation Property and later removes it through the inverse projection, then both clickable fields and the shared Action surface show one canonical edge and History records one coherent lineage. Queries, Graph, inline references, Canvas, and a possible Connections neighborhood remain broader-roadmap projections that must consume that same identity when implemented. ### Preserve knowledge when a column disappears -Given a Relation Property containing several Relationships, when a Database editor removes only the Property, then the Relationships remain visible in authorized Connections and Queries. When the editor instead chooses **Remove Property and its N relationships**, the exact authorized edges are removed in one reversible Revision and newer concurrent edges are preserved. +Given a Relation Property containing several Relationships, when a Database editor removes only the Property, then the Relationships remain available through authorized Actions but no fallback list appears in the ordinary Page UI. Independently available History can restore the same projection. When the editor instead chooses **Remove Property and its N relationships**, the exact authorized edges are removed in one reversible Revision and newer concurrent edges are preserved. ### Enforce directional cardinality atomically @@ -152,19 +155,19 @@ Given a one-parent Relationship and a bulk paste containing several proposed par ### Keep authority attached to the route -Given a person who may view both endpoints but may edit only the target Page, when they inspect an incoming directional edge in Connections, then they can see it but cannot remove it unless an editable inverse Property or other authorized route exists. Graph, Canvas, agents, and Rules return the same decision. +Given a person who may view both endpoints but may edit only the target Page, when they inspect an incoming directional edge through an authorized Action or read-only projection, then they cannot remove it unless an editable inverse Property or other authorized route exists. Future Graph, Canvas, Connections, agents, and Rules return the same decision. ### Converge without inventing history -Given one client removes the edge it observed while another concurrently re-adds it, when both commits settle, then the unseen addition remains live, no duplicate appears, and Versions shows the actual removal and re-add. A subsequent informed removal makes the edge inactive. +Given one client removes the edge it observed while another concurrently re-adds it, when both commits settle, then the unseen addition remains live, no duplicate appears, and History shows the actual removal and re-add. A subsequent informed removal makes the edge inactive. ### Protect private neighborhoods -Given an authorized Page related to an endpoint the viewer cannot access, when the viewer opens Connections, runs a Query, inspects Graph, calculates counts or rollups, or exports the Page, then the private endpoint and its existence do not leak. A known direct reference returns an honest access denial. +Given an authorized Page related to an endpoint the viewer cannot access, when the viewer uses an authorized relationship Action or a future Connections, Query, Graph, count, rollup, or export projection, then the private endpoint and its existence do not leak. A known direct reference returns an honest access denial. ## Current evidence -The first implementation slice adds local directional types within one Content space, forward one/many and inverse many cardinality, Database admission constraints, canonical Relation Property projections, Connections, bounded atomic Actions, and relationship-scoped history and recovery. It is work in progress, not verification of this entire Capability. +The first implementation slice adds local directional types within one Content space, forward one/many and inverse many cardinality, Database admission constraints, canonical clickable Relation Property projections with an optional reverse column, bounded atomic Actions, a three-state bulk editor whose Apply commits net toggles atomically, and independently available relationship History and recovery. It deliberately has no separate Connections or Other connections UI. It is work in progress, not verification of this entire Capability. Focused local integration tests cover canonical projection hydration, access-filtered export, legacy write rejection, and Page lifecycle behavior. `actions/relationship-concurrency.postgres.test.ts` exercises separate PostgreSQL connections for duplicate additions, observed removals, operation replay/conflict, and max-one replacement. Technical tests do not establish real-interface acceptance or deployed availability. @@ -172,7 +175,7 @@ The narrower slice still requires completed access/recovery review and real UI/i ## Proof plan -Proof requires deterministic Action and persistence tests plus real-interface workflows: +Verification of the entire broader-roadmap Capability requires deterministic Action and persistence tests plus real-interface workflows. The current slice proves only the projections and routes it exposes: 1. Create local, governed, symmetric, directional, self-enabled, and source-backed Relationship types; rename and version them without changing stable identity. 2. Create, edit, and remove the same edge through forward and inverse Properties, Connections, inline typed references, Graph, Canvas promotion, agents, Rules, and imports; verify identical permission and Event behavior. @@ -184,4 +187,4 @@ Proof requires deterministic Action and persistence tests plus real-interface wo ## Open questions -The core product behavior above is settled. Implementation may still choose storage layout, index strategy, causal metadata representation, and exact control placement provided those choices satisfy this contract. Federation must be separately designed before enabling canonical cross-Organization Relationships. +The core product behavior above is settled. The current slice uses clickable fields, an optional reverse column, no standalone Connections fallback, the three-state bulk list, and independent History. Implementation may still choose storage layout, index strategy, and causal metadata representation provided those choices satisfy this contract. Broader-roadmap projection controls and federation require separate design before they are enabled.