Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions templates/content/.agents/skills/document-editing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ 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
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

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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# 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.

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
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.

## 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.
78 changes: 78 additions & 0 deletions templates/content/actions/_canonical-relation-guard.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getDb>,
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<typeof getDb>,
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",
},
);
}
}
34 changes: 30 additions & 4 deletions templates/content/actions/_collection-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
computedPropertyValue,
listPropertiesForDatabase,
parseDatabaseViewConfig,
parseLegacyRelationValue,
} from "./_property-utils.js";

export interface CollectionExportRequest {
Expand Down Expand Up @@ -244,6 +245,7 @@ function propertyKey(documentId: string, propertyId: string) {
async function loadStoredValues(
documentIds: readonly string[],
propertyIds: readonly string[],
relationPropertyIds: ReadonlySet<string> = new Set(),
) {
const values = new Map<string, DocumentPropertyValue>();
for (const documentIdChunk of chunks([...documentIds], 180)) {
Expand All @@ -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),
);
}
}
Expand Down Expand Up @@ -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);
Expand All @@ -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<string>();
for (const relation of requiredRelations) {
for (const documentId of documentIds) {
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions templates/content/actions/_content-database-row-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) =>
Expand Down
10 changes: 8 additions & 2 deletions templates/content/actions/_content-spaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isComputedPropertyType,
type DocumentPropertyType,
} from "../shared/properties.js";
import { assertNotCanonicalRelationProjection } from "./_canonical-relation-guard.js";
import {
listContentOrganizationMemberships,
normalizeContentSpaceEmail,
Expand Down Expand Up @@ -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({
Expand Down
7 changes: 7 additions & 0 deletions templates/content/actions/_database-source-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
chunks,
processWithConcurrency,
} from "./_batch-utils.js";
import { assertNotCanonicalRelationProjection } from "./_canonical-relation-guard.js";
import {
LOCAL_FOLDER_SOURCE_TYPE,
localFolderSourceIdentityFromMetadata,
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions templates/content/actions/_database-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
} from "./_files-system-properties.js";
import {
listPropertiesForDatabaseDocuments,
readRelationProjectionValues,
listPropertiesForDatabase,
serializeDatabase,
} from "./_property-utils.js";
Expand Down Expand Up @@ -814,6 +815,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)
Expand Down
16 changes: 13 additions & 3 deletions templates/content/actions/_delete-content-space.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,7 +11,11 @@ import {
type Db = ReturnType<typeof getDb>;
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", {
Expand Down Expand Up @@ -53,6 +58,7 @@ async function deleteUserContentSpaceOnce(db: Db, spaceId: string) {
scopedDb,
[mapping.documentId, filesDatabase.documentId],
access.space.ownerEmail,
context,
);

const remainingDocuments = await scopedDb
Expand Down Expand Up @@ -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) ||
Expand Down
Loading
Loading