diff --git a/templates/content/.agents/skills/document-editing/references/databases.md b/templates/content/.agents/skills/document-editing/references/databases.md index 35dee4f355..e3262787a2 100644 --- a/templates/content/.agents/skills/document-editing/references/databases.md +++ b/templates/content/.agents/skills/document-editing/references/databases.md @@ -96,6 +96,18 @@ its stored name). In table views a Blocks column shows a word count (e.g. database view's column menu (not from the page body); deleting the last Blocks field warns that it removes the body for every object of the type. +For one-block agent edits, call `list-content-database-blocks` with the exact +space, database, backing document, membership row, row document, and property +IDs. Preserve its schema, row, and field revisions. Each returned block names +the operations its kind supports and carries canonical Notion-flavored Markdown +(NFM) for that one block. Pass all three revisions to +`mutate-content-database-block`; its `insert`, `update`, `upsert`, `delete`, and +`reorder` variants preserve unmentioned fields and sibling blocks. A block +value must contain exactly one top-level block of the declared kind. Reuse an +idempotency key only for an exact retry. Unsupported kinds, kind conversion, +tombstone reuse, cross-parent reorder, schema drift, and stale row or field +revisions fail explicitly. + Formula properties store their expression in property options and support `{Property name}` substitution plus simple numeric math such as `{MSV} * 2`. @@ -236,6 +248,7 @@ Use `create-content-database`, `create-inline-content-database`, `duplicate-database-items`, `remove-database-items`, `move-database-item`, `update-content-database-view`, `list-document-properties`, `configure-document-property`, `set-document-property`, +`list-content-database-blocks`, `mutate-content-database-block`, `duplicate-document-property`, and `delete-document-property`; do not edit property rows or view config via raw SQL when an action can do it. diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index d03abffd8a..3b6b110000 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -79,6 +79,8 @@ Read the relevant skill before deeper work: | `edit-document` | Find/replace edit — preferred for small changes | | `update-document` | Full rewrite of title, content, or description | | `delete-document` | Move a page and its children to Trash | +| `list-content-database-blocks` | List stable blocks and revisions in one exact database row/property | +| `mutate-content-database-block` | Insert, update, upsert, delete, or reorder one supported stable block | | `migrate-content-database-rows` | Validate, atomically apply, verify, roll back, or finalize one bounded whole-database row migration | Every action carries its own schema, and the rest of the app-specific surface diff --git a/templates/content/actions/_blocks-field-identity.ts b/templates/content/actions/_blocks-field-identity.ts index 524a28d44c..2523f93a0e 100644 --- a/templates/content/actions/_blocks-field-identity.ts +++ b/templates/content/actions/_blocks-field-identity.ts @@ -29,6 +29,17 @@ export class BlocksFieldRevisionConflictError extends Error { } } +export class BlocksFieldIdCollisionError extends Error { + readonly blockId: string; + readonly statusCode = 409; + + constructor(blockId: string) { + super(`Block ID is already owned by another Blocks field: ${blockId}`); + this.name = "BlocksFieldIdCollisionError"; + this.blockId = blockId; + } +} + function nanoid(size = 12): string { const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; @@ -265,6 +276,8 @@ export async function persistBlocksFieldIdentity(args: { previousMarkdown: string; markdown: string; expectedRevision?: number; + preferredIdsByPath?: Readonly>; + rejectCrossFieldIdRemapping?: boolean; now: string; }): Promise { const fieldId = blocksFieldId(args.documentId, args.propertyId); @@ -306,6 +319,7 @@ export async function persistBlocksFieldIdentity(args: { previous, markdown: args.markdown, createId: () => `block_${nanoid(16)}`, + preferredIdsByPath: args.preferredIdsByPath, }); if (next.blocks.length > 0) { @@ -325,6 +339,9 @@ export async function persistBlocksFieldIdentity(args: { const reserved = new Set(next.blocks.map((block) => block.id)); for (const existing of existingOwners) { if (existing.fieldId === fieldId) continue; + if (args.rejectCrossFieldIdRemapping) { + throw new BlocksFieldIdCollisionError(existing.id); + } let replacement = `block_${nanoid(16)}`; while (reserved.has(replacement)) replacement = `block_${nanoid(16)}`; reserved.add(replacement); diff --git a/templates/content/actions/_database-block-actions.ts b/templates/content/actions/_database-block-actions.ts new file mode 100644 index 0000000000..6673bed5fb --- /dev/null +++ b/templates/content/actions/_database-block-actions.ts @@ -0,0 +1,958 @@ +import { ActionContractError } from "@agent-native/core"; +import { assertAccess } from "@agent-native/core/sharing"; +import { and, eq, isNull, sql } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import { + BLOCKS_FIELD_BLOCK_KINDS, + BLOCKS_FIELD_OPERATION_CAPABILITIES, + snapshotBlocksFieldMarkdown, + type BlocksFieldBlockKind, + type BlocksFieldIdentity, +} from "../shared/blocks-field-identity.js"; +import type { + ContentDatabaseBlock, + ContentDatabaseBlockMutationReceipt, + ContentDatabaseBlockMutationResult, + ContentDatabaseBlocksReadResult, +} from "../shared/database-block-actions.js"; +import { + mutateBlocksFieldDocument, + type BlockDocumentMutation, +} from "../shared/database-block-mutations.js"; +import { + blocksStorageTarget, + isBlocksPropertyType, + parsePropertyOptions, + type BlocksStorageTarget, + type DocumentPropertyType, +} from "../shared/properties.js"; +import { + BlocksFieldIdCollisionError, + lockPrimaryBlocksFields, + persistBlocksFieldIdentity, + readBlocksFieldIdentity, +} from "./_blocks-field-identity.js"; +import { + lockContentDatabaseMutation, + touchContentDatabase, + withContentDatabaseMutationLock, +} from "./_content-database-mutation-lock.js"; +import { + assertSchema, + databaseMutationTargetSchema, + digest, + loadContext, + revisionPropertyIds, + rowSnapshot, + type DatabaseMutationTarget, + type MutationContext, + type RowSnapshot, +} from "./_database-row-mutation.js"; +import { nanoid } from "./_property-utils.js"; + +type Db = ReturnType; + +const blockKindSchema = z.enum(BLOCKS_FIELD_BLOCK_KINDS); +const blockValueSchema = z.object({ + kind: blockKindSchema, + nfm: z.string().max(1_000_000), +}); +const placementSchema = z.discriminatedUnion("placement", [ + z.object({ + placement: z.enum(["start", "end"]), + parentBlockId: z.string().min(1).nullable().optional(), + }), + z.object({ + placement: z.enum(["before", "after"]), + anchorBlockId: z.string().min(1), + }), +]); + +export const databaseBlockTargetSchema = databaseMutationTargetSchema.extend({ + itemId: z.string().min(1).describe("Exact database membership row ID"), + rowDocumentId: z.string().min(1).describe("Exact row page ID"), + propertyId: z.string().min(1).describe("Exact Blocks property ID"), +}); + +export const listDatabaseBlocksSchema = z.object({ + target: databaseBlockTargetSchema, + limit: z.coerce.number().int().min(1).max(100).default(50), + cursor: z.string().min(1).optional(), +}); + +const mutationEnvelopeSchema = z.object({ + target: databaseBlockTargetSchema, + expectedSchemaRevision: z.string().min(1), + expectedRowRevision: z.string().min(1), + expectedFieldRevision: z.number().int().nonnegative(), + idempotencyKey: z.string().min(1).max(200), +}); + +const mutateDatabaseBlockOperationSchema = z.discriminatedUnion("operation", [ + mutationEnvelopeSchema.extend({ + operation: z.literal("insert"), + block: blockValueSchema, + position: placementSchema, + }), + mutationEnvelopeSchema.extend({ + operation: z.literal("update"), + blockId: z.string().min(1), + block: blockValueSchema, + }), + mutationEnvelopeSchema.extend({ + operation: z.literal("upsert"), + blockId: z.string().min(1), + block: blockValueSchema, + position: placementSchema.optional(), + }), + mutationEnvelopeSchema.extend({ + operation: z.literal("delete"), + blockId: z.string().min(1), + }), + mutationEnvelopeSchema.extend({ + operation: z.literal("reorder"), + blockId: z.string().min(1), + position: placementSchema, + }), +]); + +type MutationInput = z.infer; + +// Agent tool registration requires a top-level object schema. Keep the +// discriminated union as the exact validator for operation-specific fields. +export const mutateDatabaseBlockSchema = z + .object({ + ...mutationEnvelopeSchema.shape, + operation: z.enum(["insert", "update", "upsert", "delete", "reorder"]), + blockId: z.string().min(1).optional(), + block: blockValueSchema.optional(), + position: placementSchema.optional(), + }) + .superRefine((value, context) => { + const parsed = mutateDatabaseBlockOperationSchema.safeParse(value); + if (parsed.success) return; + for (const issue of parsed.error.issues) { + context.addIssue({ + code: "custom", + path: issue.path, + message: issue.message, + }); + } + }) as z.ZodType; + +type BlockTarget = z.infer; + +interface LoadedField { + context: MutationContext; + row: RowSnapshot; + markdown: string; + identity: BlocksFieldIdentity; + ownerEmail: string; + storageTarget: BlocksStorageTarget; + storageRowExists: boolean; +} + +function contractError( + errorCode: string, + message: string, + details?: Record, + statusCode = 409, +): never { + throw new ActionContractError(message, { errorCode, details, statusCode }); +} + +function isUniqueConstraintError(error: unknown): boolean { + const candidate = error as { code?: unknown; message?: unknown }; + const code = String(candidate?.code ?? ""); + const message = String(candidate?.message ?? ""); + return ( + code === "23505" || + code.includes("SQLITE_CONSTRAINT") || + /unique constraint|primary key constraint|duplicate key/i.test(message) + ); +} + +function databaseTarget(target: BlockTarget): DatabaseMutationTarget { + return { + authorityScope: target.authorityScope, + spaceId: target.spaceId, + databaseId: target.databaseId, + databaseDocumentId: target.databaseDocumentId, + }; +} + +async function loadField(args: { + target: BlockTarget; + role: "viewer" | "editor"; + db?: Db; + accessAlreadyResolved?: boolean; +}): Promise { + const db = args.db ?? getDb(); + const context = await loadContext( + databaseTarget(args.target), + args.role, + db, + args.accessAlreadyResolved, + ); + if (!args.accessAlreadyResolved) { + await assertAccess("document", args.target.rowDocumentId, args.role); + } + const row = await rowSnapshot( + db, + args.target.databaseId, + args.target.itemId, + args.target.rowDocumentId, + revisionPropertyIds(context), + ); + if (!row) { + contractError( + "ROW_NOT_FOUND", + "Content database row not found.", + { + itemId: args.target.itemId, + rowDocumentId: args.target.rowDocumentId, + }, + 404, + ); + } + const definition = context.definitions.find( + (candidate) => candidate.id === args.target.propertyId, + ); + if ( + !definition || + !isBlocksPropertyType(definition.type as DocumentPropertyType) + ) { + contractError( + "BLOCKS_PROPERTY_NOT_FOUND", + "The exact property is not a Blocks field in this database schema.", + { propertyId: args.target.propertyId }, + 400, + ); + } + if (definition.systemRole) { + contractError( + "BLOCKS_PROPERTY_UNSUPPORTED", + "System Blocks properties cannot be mutated individually.", + { propertyId: definition.id }, + 400, + ); + } + if ( + args.role === "editor" && + context.sourceManagedPropertyIds.has(definition.id) + ) { + contractError( + "SOURCE_MANAGED_PROPERTY", + "Source-managed Blocks properties cannot be mutated locally.", + { propertyId: definition.id }, + 400, + ); + } + const storageTarget = blocksStorageTarget( + parsePropertyOptions(definition.optionsJson), + ); + let markdown: string; + let storageRowExists = true; + if (storageTarget === "document_body") { + markdown = row.document.content; + } else { + const [field] = await db + .select({ content: schema.documentBlockFieldContents.content }) + .from(schema.documentBlockFieldContents) + .where( + and( + eq( + schema.documentBlockFieldContents.documentId, + args.target.rowDocumentId, + ), + eq( + schema.documentBlockFieldContents.propertyId, + args.target.propertyId, + ), + ), + ); + storageRowExists = field !== undefined; + markdown = field?.content ?? ""; + } + const identity = await readBlocksFieldIdentity({ + db, + documentId: args.target.rowDocumentId, + propertyId: args.target.propertyId, + markdown, + }); + return { + context, + row, + markdown, + identity, + ownerEmail: context.database.ownerEmail, + storageTarget, + storageRowExists, + }; +} + +function serializedBlocks( + markdown: string, + identity: BlocksFieldIdentity, +): ContentDatabaseBlock[] { + const snapshots = snapshotBlocksFieldMarkdown(markdown); + if (snapshots.length !== identity.blocks.length) { + contractError( + "BLOCK_IDENTITY_STALE", + "Blocks identity does not match the current field body.", + ); + } + return identity.blocks.map((block, index) => { + const snapshot = snapshots[index]!; + if (snapshot.kind !== block.kind) { + contractError( + "BLOCK_IDENTITY_STALE", + "Blocks identity kind does not match the current field body.", + ); + } + const kind = block.kind as BlocksFieldBlockKind; + const supportedOperations = BLOCKS_FIELD_OPERATION_CAPABILITIES[kind]; + return { + id: block.id, + parentId: block.parentId, + kind, + index: block.position, + addressable: block.addressable, + value: { format: "nfm", nfm: snapshot.markdown }, + supportedOperations, + degraded: false, + }; + }); +} + +function encodeCursor(revision: number, offset: number) { + return Buffer.from(JSON.stringify({ revision, offset })).toString( + "base64url", + ); +} + +function decodeCursor(cursor: string): { revision: number; offset: number } { + try { + const parsed = JSON.parse( + Buffer.from(cursor, "base64url").toString("utf8"), + ) as { revision?: unknown; offset?: unknown }; + if ( + !Number.isInteger(parsed.revision) || + !Number.isInteger(parsed.offset) || + (parsed.revision as number) < 0 || + (parsed.offset as number) < 0 + ) { + throw new Error("invalid"); + } + return { + revision: parsed.revision as number, + offset: parsed.offset as number, + }; + } catch { + contractError("INVALID_BLOCK_CURSOR", "Block cursor is invalid.", {}, 400); + } +} + +export async function listDatabaseBlocks( + input: z.infer, +): Promise { + const loaded = await loadField({ target: input.target, role: "viewer" }); + if (loaded.identity.identityStatus === "stale") { + contractError( + "BLOCK_IDENTITY_STALE", + "The Blocks field changed without a matching identity revision.", + ); + } + const blocks = serializedBlocks(loaded.markdown, loaded.identity); + const cursor = input.cursor ? decodeCursor(input.cursor) : null; + if (cursor && cursor.revision !== loaded.identity.revision) { + contractError( + "FIELD_REVISION_CONFLICT", + "The Blocks field changed between list pages.", + { expected: cursor.revision, actual: loaded.identity.revision }, + ); + } + const offset = cursor?.offset ?? 0; + if (offset > blocks.length) { + contractError( + "INVALID_BLOCK_CURSOR", + "Block cursor is out of range.", + {}, + 400, + ); + } + const pageBlocks = blocks.slice(offset, offset + input.limit); + const nextOffset = offset + pageBlocks.length; + return { + target: input.target, + rowLink: { + urlPath: `/page/${input.target.rowDocumentId}`, + label: "Open database row", + }, + schemaRevision: loaded.context.schemaRevision, + rowRevision: loaded.row.revision, + fieldRevision: loaded.identity.revision, + identityStatus: loaded.identity.identityStatus, + total: blocks.length, + order: blocks.map((block) => block.id), + blocks: pageBlocks, + page: { + offset, + limit: input.limit, + nextCursor: + nextOffset < blocks.length + ? encodeCursor(loaded.identity.revision, nextOffset) + : null, + }, + }; +} + +function mutationDigest(input: MutationInput) { + return digest({ contract: "content-database-block-mutation-v1", input }); +} + +function actionMutation( + input: MutationInput, + identity: BlocksFieldIdentity, + generatedInsertId: string, +): { mutation: BlockDocumentMutation; insertedBlockId?: string } { + if (input.operation === "insert") { + return { + mutation: { + operation: "insert", + block: input.block, + position: input.position, + }, + insertedBlockId: generatedInsertId, + }; + } + if (input.operation === "upsert") { + const live = identity.blocks.find((block) => block.id === input.blockId); + if (live) { + return { + mutation: { + operation: "upsert", + blockId: input.blockId, + block: input.block, + position: input.position, + }, + }; + } + if (identity.tombstones.some((block) => block.id === input.blockId)) { + contractError( + "BLOCK_ID_TOMBSTONED", + "Upsert cannot silently restore a tombstoned block ID.", + { blockId: input.blockId }, + ); + } + if (!input.position) { + contractError( + "BLOCK_POSITION_REQUIRED", + "Upserting a new block requires an exact position.", + { blockId: input.blockId }, + 400, + ); + } + return { + mutation: { + operation: "insert", + block: input.block, + position: input.position, + }, + insertedBlockId: input.blockId, + }; + } + return { mutation: input as BlockDocumentMutation }; +} + +function mapMutationFailure(error: unknown): never { + if (error instanceof ActionContractError) throw error; + const message = error instanceof Error ? error.message : String(error); + const errorCode = message.includes("does not support") + ? "BLOCK_OPERATION_UNSUPPORTED" + : message.includes("Cross-parent") || + message.includes("outside the current parent") + ? "CROSS_PARENT_REORDER_UNSUPPORTED" + : message.includes("cannot change block kind") + ? "BLOCK_KIND_MISMATCH" + : message.includes("not found") + ? "BLOCK_NOT_FOUND" + : "INVALID_BLOCK_VALUE"; + contractError( + errorCode, + message, + {}, + errorCode === "INVALID_BLOCK_VALUE" ? 400 : 409, + ); +} + +async function writeMarkdown( + db: Db, + loaded: LoadedField, + target: BlockTarget, + markdown: string, + now: string, +) { + if (loaded.storageTarget === "document_body") { + const updated = await db + .update(schema.documents) + .set({ content: markdown, updatedAt: now }) + .where( + and( + eq(schema.documents.id, target.rowDocumentId), + eq(schema.documents.content, loaded.markdown), + isNull(schema.documents.trashedAt), + ), + ) + .returning({ id: schema.documents.id }); + if (updated.length === 0) { + contractError( + "FIELD_REVISION_CONFLICT", + "The Blocks field changed before the mutation could commit.", + { propertyId: target.propertyId }, + ); + } + return; + } + await compareAndSwapAdditionalBlocksField({ + db, + ownerEmail: loaded.ownerEmail, + documentId: target.rowDocumentId, + propertyId: target.propertyId, + expectedContent: loaded.markdown, + expectedExists: loaded.storageRowExists, + content: markdown, + now, + }); +} + +export async function compareAndSwapAdditionalBlocksField(args: { + db: ReturnType; + ownerEmail: string; + documentId: string; + propertyId: string; + expectedContent: string; + expectedExists: boolean; + content: string; + now: string; +}) { + const updated = args.expectedExists + ? await args.db + .update(schema.documentBlockFieldContents) + .set({ content: args.content, updatedAt: args.now }) + .where( + and( + eq(schema.documentBlockFieldContents.documentId, args.documentId), + eq(schema.documentBlockFieldContents.propertyId, args.propertyId), + eq(schema.documentBlockFieldContents.content, args.expectedContent), + ), + ) + .returning({ id: schema.documentBlockFieldContents.id }) + : await args.db + .insert(schema.documentBlockFieldContents) + .values({ + id: nanoid(), + ownerEmail: args.ownerEmail, + documentId: args.documentId, + propertyId: args.propertyId, + content: args.content, + createdAt: args.now, + updatedAt: args.now, + }) + .onConflictDoNothing({ + target: [ + schema.documentBlockFieldContents.documentId, + schema.documentBlockFieldContents.propertyId, + ], + }) + .returning({ id: schema.documentBlockFieldContents.id }); + if (updated.length === 0) { + contractError( + "FIELD_REVISION_CONFLICT", + "The Blocks field changed before the mutation could commit.", + { propertyId: args.propertyId }, + ); + } +} + +async function readExistingReceipt( + databaseId: string, + idempotencyKey: string, + expectedDigest: string, + db: Db = getDb(), +): Promise { + const [stored] = await db + .select() + .from(schema.contentDatabaseRowMutationReceipts) + .where( + and( + eq(schema.contentDatabaseRowMutationReceipts.databaseId, databaseId), + eq( + schema.contentDatabaseRowMutationReceipts.idempotencyKey, + idempotencyKey, + ), + ), + ); + if (!stored) return null; + if (stored.payloadDigest !== expectedDigest) { + contractError( + "IDEMPOTENCY_KEY_REUSED", + "This idempotency key was already used for a different mutation.", + { idempotencyKey }, + ); + } + const parsed = JSON.parse( + stored.resultJson, + ) as ContentDatabaseBlockMutationResult; + if (!parsed.receipt?.target?.propertyId) { + contractError( + "IDEMPOTENCY_KEY_REUSED", + "This idempotency key belongs to a different mutation contract.", + { idempotencyKey }, + ); + } + return { + receipt: { + ...parsed.receipt, + idempotency: { ...parsed.receipt.idempotency, result: "replayed" }, + }, + }; +} + +async function verifyResult( + result: ContentDatabaseBlockMutationResult, + db: Db = getDb(), +): Promise { + const loaded = await loadField({ + target: result.receipt.target, + role: "viewer", + db, + }); + const blocks = serializedBlocks(loaded.markdown, loaded.identity); + const order = blocks.map((block) => block.id); + if ( + loaded.row.revision !== result.receipt.revisions.row.after || + loaded.identity.revision !== result.receipt.revisions.field.after || + loaded.identity.contentHash !== result.receipt.readback.contentHash || + JSON.stringify(order) !== JSON.stringify(result.receipt.readback.order) + ) { + contractError( + "IDEMPOTENCY_REPLAY_DRIFT", + "The committed Blocks field changed after this mutation.", + { receiptId: result.receipt.receiptId }, + ); + } + return { + receipt: { + ...result.receipt, + readback: { + verified: true, + fieldRevision: loaded.identity.revision, + contentHash: loaded.identity.contentHash, + order, + blocks, + }, + }, + }; +} + +function outcomeFor( + input: MutationInput, + changed: boolean, + inserted: boolean, +): ContentDatabaseBlockMutationReceipt["outcome"] { + if (!changed) return "unchanged"; + if (inserted) return "inserted"; + if (input.operation === "delete") return "deleted"; + if (input.operation === "reorder") return "reordered"; + return "updated"; +} + +export async function mutateDatabaseBlock( + input: MutationInput, +): Promise { + const inputDigest = mutationDigest(input); + await loadField({ target: input.target, role: "editor" }); + const result = await withContentDatabaseMutationLock( + input.target.databaseId, + async () => { + const replay = await readExistingReceipt( + input.target.databaseId, + input.idempotencyKey, + inputDigest, + ); + if (replay) return replay; + return getDb().transaction(async (transaction) => { + const tx = transaction as unknown as Db; + await lockContentDatabaseMutation(tx, input.target.databaseId); + const lockedReplay = await readExistingReceipt( + input.target.databaseId, + input.idempotencyKey, + inputDigest, + tx, + ); + if (lockedReplay) return lockedReplay; + const primaryBlocksFields = await lockPrimaryBlocksFields( + tx, + input.target.rowDocumentId, + ); + const [lockedDocument] = await tx + .update(schema.documents) + .set({ updatedAt: sql`${schema.documents.updatedAt}` }) + .where( + and( + eq(schema.documents.id, input.target.rowDocumentId), + isNull(schema.documents.trashedAt), + ), + ) + .returning({ id: schema.documents.id }); + if (!lockedDocument) { + contractError( + "ROW_NOT_FOUND", + "The exact database row was not found.", + { + documentId: input.target.rowDocumentId, + }, + 404, + ); + } + const loaded = await loadField({ + target: input.target, + role: "editor", + db: tx, + accessAlreadyResolved: true, + }); + assertSchema(loaded.context, input.expectedSchemaRevision); + if (loaded.row.revision !== input.expectedRowRevision) { + contractError("ROW_REVISION_CONFLICT", "The database row changed.", { + expected: input.expectedRowRevision, + actual: loaded.row.revision, + }); + } + if (loaded.identity.identityStatus === "stale") { + contractError( + "BLOCK_IDENTITY_STALE", + "The Blocks field changed without a matching identity revision.", + ); + } + if (loaded.identity.revision !== input.expectedFieldRevision) { + contractError( + "FIELD_REVISION_CONFLICT", + "The Blocks field changed.", + { + expected: input.expectedFieldRevision, + actual: loaded.identity.revision, + }, + ); + } + const generatedInsertId = `block_${nanoid(16)}`; + const resolved = actionMutation( + input, + loaded.identity, + generatedInsertId, + ); + if (resolved.insertedBlockId) { + const [used] = await tx + .select({ id: schema.documentBlocks.id }) + .from(schema.documentBlocks) + .where(eq(schema.documentBlocks.id, resolved.insertedBlockId)); + if (used) { + contractError( + "BLOCK_ID_ALREADY_USED", + "The requested block ID has already been used.", + { blockId: resolved.insertedBlockId }, + ); + } + } + let changed; + try { + changed = mutateBlocksFieldDocument({ + markdown: loaded.markdown, + identity: loaded.identity, + mutation: resolved.mutation, + insertedBlockId: resolved.insertedBlockId, + createInsertedDescendantId: () => `block_${nanoid(16)}`, + }); + } catch (error) { + mapMutationFailure(error); + } + const now = new Date().toISOString(); + let postIdentity = loaded.identity; + if (changed.changed) { + await writeMarkdown(tx, loaded, input.target, changed.markdown, now); + try { + const fieldsToPersist = + loaded.storageTarget === "document_body" + ? primaryBlocksFields + : [ + { + propertyId: input.target.propertyId, + ownerEmail: loaded.ownerEmail, + }, + ]; + if ( + !fieldsToPersist.some( + (field) => field.propertyId === input.target.propertyId, + ) + ) { + throw new Error( + "Primary Blocks membership changed before the operation completed.", + ); + } + for (const field of fieldsToPersist) { + const isTarget = field.propertyId === input.target.propertyId; + await persistBlocksFieldIdentity({ + db: tx, + ownerEmail: field.ownerEmail, + documentId: input.target.rowDocumentId, + propertyId: field.propertyId, + previousMarkdown: loaded.markdown, + markdown: changed.markdown, + ...(isTarget + ? { + expectedRevision: input.expectedFieldRevision, + preferredIdsByPath: changed.preferredIdsByPath, + rejectCrossFieldIdRemapping: true, + } + : {}), + now, + }); + } + } catch (error) { + if ( + error instanceof BlocksFieldIdCollisionError || + (resolved.insertedBlockId && isUniqueConstraintError(error)) + ) { + contractError( + "BLOCK_ID_ALREADY_USED", + "The requested block ID has already been used.", + { + blockId: + error instanceof BlocksFieldIdCollisionError + ? error.blockId + : resolved.insertedBlockId, + }, + ); + } + throw error; + } + postIdentity = await readBlocksFieldIdentity({ + db: tx, + documentId: input.target.rowDocumentId, + propertyId: input.target.propertyId, + markdown: changed.markdown, + }); + if ( + resolved.insertedBlockId && + !postIdentity.blocks.some( + (block) => block.id === resolved.insertedBlockId, + ) + ) { + contractError( + "BLOCK_ID_ALREADY_USED", + "The requested block ID has already been used.", + { blockId: resolved.insertedBlockId }, + ); + } + await touchContentDatabase(tx, input.target.databaseId, now); + } + const postRow = await rowSnapshot( + tx, + input.target.databaseId, + input.target.itemId, + input.target.rowDocumentId, + revisionPropertyIds(loaded.context), + ); + if (!postRow) { + contractError( + "READBACK_MISMATCH", + "The database row disappeared during block mutation.", + ); + } + const postBlocks = serializedBlocks(changed.markdown, postIdentity); + const preOrder = loaded.identity.blocks.map((block) => block.id); + const postOrder = postBlocks.map((block) => block.id); + const deletedBlockIds = preOrder.filter( + (blockId) => !postOrder.includes(blockId), + ); + const insertedBlockIds = postOrder.filter( + (blockId) => !preOrder.includes(blockId), + ); + const primaryBlockId = + resolved.insertedBlockId ?? + ("blockId" in input ? input.blockId : null); + const affectedBlockIds = [ + ...new Set([ + ...(primaryBlockId ? [primaryBlockId] : []), + ...insertedBlockIds, + ...deletedBlockIds, + ]), + ]; + const receiptId = `block_receipt_${nanoid(18)}`; + const receipt: ContentDatabaseBlockMutationReceipt = { + receiptId, + operation: input.operation, + outcome: outcomeFor( + input, + changed.changed, + resolved.insertedBlockId !== undefined, + ), + target: input.target, + rowLink: { + urlPath: `/page/${input.target.rowDocumentId}`, + label: "Open database row", + }, + schemaRevision: loaded.context.schemaRevision, + idempotency: { + key: input.idempotencyKey, + result: "applied", + payloadDigest: inputDigest, + }, + revisions: { + row: { before: loaded.row.revision, after: postRow.revision }, + field: { + before: loaded.identity.revision, + after: postIdentity.revision, + }, + }, + affected: { + blockIds: affectedBlockIds, + deletedBlockIds, + order: postOrder, + }, + readback: { + verified: true, + fieldRevision: postIdentity.revision, + contentHash: postIdentity.contentHash, + order: postOrder, + blocks: postBlocks, + }, + }; + const storedResult: ContentDatabaseBlockMutationResult = { receipt }; + await tx.insert(schema.contentDatabaseRowMutationReceipts).values({ + id: receiptId, + ownerEmail: loaded.ownerEmail, + orgId: loaded.context.database.orgId, + spaceId: input.target.spaceId, + databaseId: input.target.databaseId, + databaseDocumentId: input.target.databaseDocumentId, + operation: `block:${input.operation}`, + itemId: input.target.itemId, + documentId: input.target.rowDocumentId, + idempotencyKey: input.idempotencyKey, + payloadDigest: inputDigest, + schemaRevision: loaded.context.schemaRevision, + preRowRevision: loaded.row.revision, + postRowRevision: postRow.revision, + resultJson: JSON.stringify(storedResult), + createdAt: now, + updatedAt: now, + }); + return storedResult; + }); + }, + ); + return verifyResult(result); +} diff --git a/templates/content/actions/_database-row-mutation.ts b/templates/content/actions/_database-row-mutation.ts index 67a2b2a4f5..5bab2f32e3 100644 --- a/templates/content/actions/_database-row-mutation.ts +++ b/templates/content/actions/_database-row-mutation.ts @@ -64,7 +64,7 @@ type DatabaseRow = typeof schema.contentDatabases.$inferSelect; type DefinitionRow = typeof schema.documentPropertyDefinitions.$inferSelect; type Db = ReturnType; -interface MutationContext { +export interface MutationContext { database: DatabaseRow; databaseDocument: typeof schema.documents.$inferSelect; definitions: DefinitionRow[]; @@ -72,7 +72,7 @@ interface MutationContext { schemaRevision: string; } -interface RowSnapshot { +export interface RowSnapshot { item: typeof schema.contentDatabaseItems.$inferSelect; document: typeof schema.documents.$inferSelect; values: Map; @@ -111,7 +111,7 @@ function canonical(value: unknown): string { .join(",")}}`; } -function digest(value: unknown): string { +export function digest(value: unknown): string { return `sha256:${createHash("sha256").update(canonical(value)).digest("hex")}`; } @@ -210,7 +210,7 @@ function acceptedShape(type: DocumentPropertyType): string { } } -async function loadContext( +export async function loadContext( target: DatabaseMutationTarget, role: "viewer" | "editor", db: Db = getDb(), @@ -631,7 +631,7 @@ async function ensureNaturalKeyClaim( } } -async function rowSnapshot( +export async function rowSnapshot( db: Db, databaseId: string, itemId: string, @@ -683,7 +683,7 @@ async function rowSnapshot( return { ...row, values: valueMap, revision }; } -function revisionPropertyIds(context: MutationContext) { +export function revisionPropertyIds(context: MutationContext) { return new Set( context.definitions .filter( @@ -851,7 +851,7 @@ async function insertReceipt( }); } -function assertSchema(context: MutationContext, expected: string) { +export function assertSchema(context: MutationContext, expected: string) { if (context.schemaRevision !== expected) { conflict("SCHEMA_REVISION_CONFLICT", "The database schema changed.", { expected, diff --git a/templates/content/actions/content-database-block-actions.db.test.ts b/templates/content/actions/content-database-block-actions.db.test.ts new file mode 100644 index 0000000000..d7eb8395d7 --- /dev/null +++ b/templates/content/actions/content-database-block-actions.db.test.ts @@ -0,0 +1,782 @@ +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + runFrameworkReleaseMigrations, + runWithRequestContext, +} from "@agent-native/core/server"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// guard:allow-unscoped — isolated SQLite fixtures intentionally inspect exact rows. + +const TEST_DB_PATH = join( + tmpdir(), + `content-block-actions-${process.pid}-${Date.now()}.sqlite`, +); +const TEST_DATABASE_URL = + process.env.CONTENT_BLOCK_ACTION_POSTGRES_URL ?? `file:${TEST_DB_PATH}`; +const OWNER = "owner@example.com"; +const OUTSIDER = "outsider@example.com"; + +type Schema = typeof import("../server/db/schema.js"); +let getDb: () => any; +let schema: Schema; +let createDatabase: typeof import("./create-content-database.js").default; +let configureProperty: typeof import("./configure-document-property.js").default; +let getDatabase: typeof import("./get-content-database.js").default; +let createRow: typeof import("./add-database-item.js").default; +let setProperty: typeof import("./set-document-property.js").default; +let listBlocks: typeof import("./list-content-database-blocks.js").default; +let mutateBlock: typeof import("./mutate-content-database-block.js").default; +let compareAndSwapAdditionalBlocksField: typeof import("./_database-block-actions.js").compareAndSwapAdditionalBlocksField; +let persistBlocksFieldIdentity: typeof import("./_blocks-field-identity.js").persistBlocksFieldIdentity; + +const asOwner = (run: () => Promise) => + runWithRequestContext({ userEmail: OWNER }, run); + +beforeAll(async () => { + if (TEST_DATABASE_URL.startsWith("postgres")) { + const databaseName = new URL(TEST_DATABASE_URL).pathname.toLowerCase(); + if (!databaseName.includes("test")) { + throw new Error( + "CONTENT_BLOCK_ACTION_POSTGRES_URL must name an isolated test database.", + ); + } + } + process.env.DATABASE_URL = TEST_DATABASE_URL; + const dbModule = await import("../server/db/index.js"); + getDb = dbModule.getDb; + schema = dbModule.schema; + createDatabase = (await import("./create-content-database.js")).default; + configureProperty = (await import("./configure-document-property.js")) + .default; + getDatabase = (await import("./get-content-database.js")).default; + createRow = (await import("./add-database-item.js")).default; + setProperty = (await import("./set-document-property.js")).default; + listBlocks = (await import("./list-content-database-blocks.js")).default; + mutateBlock = (await import("./mutate-content-database-block.js")).default; + compareAndSwapAdditionalBlocksField = ( + await import("./_database-block-actions.js") + ).compareAndSwapAdditionalBlocksField; + persistBlocksFieldIdentity = (await import("./_blocks-field-identity.js")) + .persistBlocksFieldIdentity; + if (TEST_DATABASE_URL.startsWith("postgres")) { + await runFrameworkReleaseMigrations(undefined); + } + const plugin = (await import("../server/plugins/db.js")).default; + await plugin(undefined as any); +}, 60_000); + +afterAll(() => { + if (!TEST_DATABASE_URL.startsWith("file:")) return; + for (const suffix of ["", "-shm", "-wal"]) { + rmSync(`${TEST_DB_PATH}${suffix}`, { force: true }); + } +}); + +async function fixture(initialMarkdown = "Alpha\nBeta\nGamma") { + const created = await asOwner(() => + createDatabase.run({ title: "Block action fixture" }), + ); + const databaseId = created.database.id; + const databaseDocumentId = created.database.documentId; + const initial = await asOwner(() => getDatabase.run({ databaseId })); + if (!("database" in initial) || !initial.mutationContract) { + throw new Error("Fixture database has no mutation contract."); + } + const primary = initial.mutationContract.properties.find( + (property) => property.type === "blocks", + ); + if (!primary) throw new Error("Fixture database has no Blocks property."); + const row = await asOwner(() => + createRow.run({ + target: initial.mutationContract!.target, + expectedSchemaRevision: initial.mutationContract!.schemaRevision, + idempotencyKey: `create-row-${databaseId}`, + title: "Fixture row", + }), + ); + await asOwner(() => + setProperty.run({ + databaseId, + documentId: row.receipt.row.documentId, + propertyId: primary.id, + value: initialMarkdown, + expectedBlocksFieldRevision: 0, + }), + ); + const current = await asOwner(() => getDatabase.run({ databaseId })); + if (!("database" in current) || !current.mutationContract) { + throw new Error("Fixture database disappeared."); + } + const target = { + ...current.mutationContract.target, + itemId: row.receipt.row.itemId, + rowDocumentId: row.receipt.row.documentId, + propertyId: primary.id, + }; + const listed = await asOwner(() => listBlocks.run({ target, limit: 100 })); + return { databaseId, databaseDocumentId, target, listed }; +} + +function envelope( + fixture: Awaited>, + idempotencyKey: string, +) { + return { + target: fixture.target, + expectedSchemaRevision: fixture.listed.schemaRevision, + expectedRowRevision: fixture.listed.rowRevision, + expectedFieldRevision: fixture.listed.fieldRevision, + idempotencyKey, + }; +} + +describe("exact Content database block actions", () => { + it("rejects cross-field remapping of an action-preferred block ID", async () => { + const target = await fixture("Target"); + const owner = await fixture("Owner"); + const requestedId = owner.listed.blocks[0]!.id; + + await expect( + persistBlocksFieldIdentity({ + db: getDb(), + ownerEmail: OWNER, + documentId: target.target.rowDocumentId, + propertyId: target.target.propertyId, + previousMarkdown: "Target", + markdown: "Target\nInserted", + expectedRevision: target.listed.fieldRevision, + preferredIdsByPath: { + "0": target.listed.blocks[0]!.id, + "1": requestedId, + }, + rejectCrossFieldIdRemapping: true, + now: new Date().toISOString(), + }), + ).rejects.toMatchObject({ + name: "BlocksFieldIdCollisionError", + blockId: requestedId, + }); + }); + + it("lists revision-pinned pages and performs every supported operation with verified retry receipts", async () => { + const state = await fixture(); + const [alpha, beta, gamma] = state.listed.blocks; + expect(state.listed).toMatchObject({ + total: 3, + identityStatus: "materialized", + rowLink: { urlPath: `/page/${state.target.rowDocumentId}` }, + }); + const firstPage = await asOwner(() => + listBlocks.run({ target: state.target, limit: 1 }), + ); + expect(firstPage.page.nextCursor).toBeTruthy(); + + const insertInput = { + ...envelope(state, "insert-middle"), + operation: "insert" as const, + block: { kind: "paragraph" as const, nfm: "Middle" }, + position: { placement: "before" as const, anchorBlockId: beta!.id }, + }; + const inserted = await asOwner(() => mutateBlock.run(insertInput)); + const insertedId = inserted.receipt.affected.blockIds.find( + (id) => !state.listed.order.includes(id), + ); + expect(insertedId).toBeTruthy(); + expect(inserted.receipt).toMatchObject({ + operation: "insert", + outcome: "inserted", + idempotency: { key: "insert-middle", result: "applied" }, + readback: { verified: true }, + }); + expect(inserted.receipt.affected.order).toEqual([ + alpha!.id, + insertedId, + beta!.id, + gamma!.id, + ]); + + const replayed = await asOwner(() => mutateBlock.run(insertInput)); + expect(replayed.receipt.receiptId).toBe(inserted.receipt.receiptId); + expect(replayed.receipt.idempotency.result).toBe("replayed"); + await expect( + asOwner(() => + mutateBlock.run({ + ...insertInput, + block: { kind: "paragraph", nfm: "Different payload" }, + }), + ), + ).rejects.toMatchObject({ errorCode: "IDEMPOTENCY_KEY_REUSED" }); + await expect( + asOwner(() => + listBlocks.run({ + target: state.target, + limit: 1, + cursor: firstPage.page.nextCursor!, + }), + ), + ).rejects.toMatchObject({ errorCode: "FIELD_REVISION_CONFLICT" }); + + let current = await asOwner(() => + listBlocks.run({ target: state.target, limit: 100 }), + ); + const updated = await asOwner(() => + mutateBlock.run({ + target: state.target, + expectedSchemaRevision: current.schemaRevision, + expectedRowRevision: current.rowRevision, + expectedFieldRevision: current.fieldRevision, + idempotencyKey: "update-inserted", + operation: "update", + blockId: insertedId!, + block: { kind: "paragraph", nfm: "Middle updated" }, + }), + ); + expect( + updated.receipt.readback.blocks.find((block) => block.id === insertedId) + ?.value.nfm, + ).toBe("Middle updated"); + + current = await asOwner(() => + listBlocks.run({ target: state.target, limit: 100 }), + ); + const reordered = await asOwner(() => + mutateBlock.run({ + target: state.target, + expectedSchemaRevision: current.schemaRevision, + expectedRowRevision: current.rowRevision, + expectedFieldRevision: current.fieldRevision, + idempotencyKey: "reorder-inserted", + operation: "reorder", + blockId: insertedId!, + position: { placement: "after", anchorBlockId: gamma!.id }, + }), + ); + expect(reordered.receipt.affected.order.at(-1)).toBe(insertedId); + + current = await asOwner(() => + listBlocks.run({ target: state.target, limit: 100 }), + ); + const existingUpsert = await asOwner(() => + mutateBlock.run({ + target: state.target, + expectedSchemaRevision: current.schemaRevision, + expectedRowRevision: current.rowRevision, + expectedFieldRevision: current.fieldRevision, + idempotencyKey: "upsert-existing", + operation: "upsert", + blockId: beta!.id, + block: { kind: "paragraph", nfm: "Beta upserted" }, + position: { placement: "start" }, + }), + ); + expect(existingUpsert.receipt.affected.blockIds).toContain(beta!.id); + expect(existingUpsert.receipt.affected.order[0]).toBe(beta!.id); + + current = await asOwner(() => + listBlocks.run({ target: state.target, limit: 100 }), + ); + const requestedId = `block_requested_${Date.now()}`; + const insertedUpsert = await asOwner(() => + mutateBlock.run({ + target: state.target, + expectedSchemaRevision: current.schemaRevision, + expectedRowRevision: current.rowRevision, + expectedFieldRevision: current.fieldRevision, + idempotencyKey: "upsert-new", + operation: "upsert", + blockId: requestedId, + block: { kind: "paragraph", nfm: "Caller ID" }, + position: { placement: "start" }, + }), + ); + expect(insertedUpsert.receipt.affected.order[0]).toBe(requestedId); + expect(insertedUpsert.receipt.outcome).toBe("inserted"); + + current = await asOwner(() => + listBlocks.run({ target: state.target, limit: 100 }), + ); + const deleted = await asOwner(() => + mutateBlock.run({ + target: state.target, + expectedSchemaRevision: current.schemaRevision, + expectedRowRevision: current.rowRevision, + expectedFieldRevision: current.fieldRevision, + idempotencyKey: "delete-alpha", + operation: "delete", + blockId: alpha!.id, + }), + ); + expect(deleted.receipt.affected.deletedBlockIds).toContain(alpha!.id); + current = await asOwner(() => + listBlocks.run({ target: state.target, limit: 100 }), + ); + await asOwner(() => + mutateBlock.run({ + target: state.target, + expectedSchemaRevision: current.schemaRevision, + expectedRowRevision: current.rowRevision, + expectedFieldRevision: current.fieldRevision, + idempotencyKey: "insert-identical-to-tombstone", + operation: "insert", + block: { kind: "paragraph", nfm: "Alpha" }, + position: { placement: "start" }, + }), + ); + current = await asOwner(() => + listBlocks.run({ target: state.target, limit: 100 }), + ); + await expect( + asOwner(() => + mutateBlock.run({ + target: state.target, + expectedSchemaRevision: current.schemaRevision, + expectedRowRevision: current.rowRevision, + expectedFieldRevision: current.fieldRevision, + idempotencyKey: "restore-tombstone", + operation: "upsert", + blockId: alpha!.id, + block: { kind: "paragraph", nfm: "Not a restore" }, + position: { placement: "start" }, + }), + ), + ).rejects.toMatchObject({ errorCode: "BLOCK_ID_TOMBSTONED" }); + + await expect( + asOwner(() => mutateBlock.run(insertInput)), + ).rejects.toMatchObject({ errorCode: "IDEMPOTENCY_REPLAY_DRIFT" }); + }); + + it("keeps every primary Blocks identity current when one page belongs to multiple databases", async () => { + const first = await fixture("Alpha\nBeta"); + const secondCreated = await asOwner(() => + createDatabase.run({ title: "Second primary identity" }), + ); + const secondDatabaseId = secondCreated.database.id; + const secondRead = await asOwner(() => + getDatabase.run({ databaseId: secondDatabaseId }), + ); + if (!("database" in secondRead) || !secondRead.mutationContract) { + throw new Error("Second fixture database has no mutation contract."); + } + const secondPrimary = secondRead.mutationContract.properties.find( + (property) => property.type === "blocks", + ); + if (!secondPrimary) + throw new Error("Second fixture has no Blocks property."); + const now = new Date().toISOString(); + const secondItemId = `shared-item-${Date.now()}`; + await getDb().insert(schema.contentDatabaseItems).values({ + id: secondItemId, + ownerEmail: OWNER, + orgId: secondRead.database.orgId, + databaseId: secondDatabaseId, + documentId: first.target.rowDocumentId, + position: 0, + createdAt: now, + updatedAt: now, + }); + const secondTarget = { + ...secondRead.mutationContract.target, + itemId: secondItemId, + rowDocumentId: first.target.rowDocumentId, + propertyId: secondPrimary.id, + }; + await asOwner(() => + setProperty.run({ + databaseId: secondDatabaseId, + documentId: first.target.rowDocumentId, + propertyId: secondPrimary.id, + value: "Alpha\nBeta", + expectedBlocksFieldRevision: 0, + }), + ); + const firstBefore = await asOwner(() => + listBlocks.run({ target: first.target, limit: 100 }), + ); + const secondBefore = await asOwner(() => + listBlocks.run({ target: secondTarget, limit: 100 }), + ); + + await asOwner(() => + mutateBlock.run({ + target: first.target, + expectedSchemaRevision: firstBefore.schemaRevision, + expectedRowRevision: firstBefore.rowRevision, + expectedFieldRevision: firstBefore.fieldRevision, + idempotencyKey: "multi-primary-update", + operation: "update", + blockId: firstBefore.blocks[0]!.id, + block: { kind: "paragraph", nfm: "Alpha updated" }, + }), + ); + + const secondAfter = await asOwner(() => + listBlocks.run({ target: secondTarget, limit: 100 }), + ); + expect(secondAfter.identityStatus).toBe("materialized"); + expect(secondAfter.fieldRevision).toBe(secondBefore.fieldRevision + 1); + expect(secondAfter.blocks.map((block) => block.value.nfm)).toEqual([ + "Alpha updated", + "Beta", + ]); + expect(secondAfter.order).toEqual(secondBefore.order); + }); + + it("rejects stale row, field, schema, target, access, and unsupported operations without clobbering", async () => { + const state = await fixture("- one\n- two\n\nSibling"); + const listItem = state.listed.blocks.find( + (block) => block.kind === "listItem", + )!; + const before = state.listed.order; + await expect( + asOwner(() => + mutateBlock.run({ + ...envelope(state, "unsupported-list-update"), + operation: "update", + blockId: listItem.id, + block: { kind: "listItem", nfm: "- changed" }, + }), + ), + ).rejects.toMatchObject({ errorCode: "BLOCK_OPERATION_UNSUPPORTED" }); + const unchanged = await asOwner(() => + listBlocks.run({ target: state.target, limit: 100 }), + ); + expect(unchanged.order).toEqual(before); + expect(unchanged.fieldRevision).toBe(state.listed.fieldRevision); + + const sibling = state.listed.blocks.find( + (block) => block.kind === "paragraph", + )!; + await expect( + asOwner(() => + mutateBlock.run({ + ...envelope(state, "leaf-parent"), + operation: "insert", + block: { kind: "paragraph", nfm: "Nested" }, + position: { placement: "end", parentBlockId: sibling.id }, + }), + ), + ).rejects.toMatchObject({ errorCode: "INVALID_BLOCK_VALUE" }); + const afterLeafRejection = await asOwner(() => + listBlocks.run({ target: state.target, limit: 100 }), + ); + expect(afterLeafRejection.order).toEqual(before); + expect(afterLeafRejection.fieldRevision).toBe(state.listed.fieldRevision); + + await expect( + asOwner(() => + mutateBlock.run({ + ...envelope(state, "stale-row"), + expectedRowRevision: "sha256:stale", + operation: "delete", + blockId: listItem.id, + }), + ), + ).rejects.toMatchObject({ errorCode: "ROW_REVISION_CONFLICT" }); + await expect( + asOwner(() => + mutateBlock.run({ + ...envelope(state, "stale-field"), + expectedFieldRevision: state.listed.fieldRevision + 1, + operation: "delete", + blockId: listItem.id, + }), + ), + ).rejects.toMatchObject({ errorCode: "FIELD_REVISION_CONFLICT" }); + await expect( + asOwner(() => + mutateBlock.run({ + ...envelope(state, "wrong-target"), + target: { ...state.target, spaceId: "wrong-space" }, + operation: "delete", + blockId: listItem.id, + }), + ), + ).rejects.toMatchObject({ errorCode: "TARGET_MISMATCH" }); + await expect( + runWithRequestContext({ userEmail: OUTSIDER }, () => + listBlocks.run({ target: state.target, limit: 100 }), + ), + ).rejects.toThrow(); + + await asOwner(() => + configureProperty.run({ + documentId: state.databaseDocumentId, + databaseId: state.databaseId, + name: "Schema drift", + type: "text", + }), + ); + await expect( + asOwner(() => + mutateBlock.run({ + ...envelope(state, "stale-schema"), + operation: "delete", + blockId: listItem.id, + }), + ), + ).rejects.toMatchObject({ errorCode: "SCHEMA_REVISION_CONFLICT" }); + }); + + it("mutates an additional Blocks property without changing the primary field or another property", async () => { + const state = await fixture("Primary stays"); + const added = await asOwner(() => + configureProperty.run({ + documentId: state.databaseDocumentId, + databaseId: state.databaseId, + name: "Research notes", + type: "blocks", + }), + ); + const additional = added.properties.find( + (property) => property.definition.name === "Research notes", + )!; + await asOwner(() => + setProperty.run({ + databaseId: state.databaseId, + documentId: state.target.rowDocumentId, + propertyId: additional.definition.id, + value: "Notes A\nNotes B", + expectedBlocksFieldRevision: 0, + }), + ); + const discovered = await asOwner(() => + getDatabase.run({ databaseId: state.databaseId }), + ); + if (!("database" in discovered) || !discovered.mutationContract) { + throw new Error("Fixture database disappeared."); + } + const additionalTarget = { + ...discovered.mutationContract.target, + itemId: state.target.itemId, + rowDocumentId: state.target.rowDocumentId, + propertyId: additional.definition.id, + }; + const additionalBefore = await asOwner(() => + listBlocks.run({ target: additionalTarget, limit: 100 }), + ); + await asOwner(() => + mutateBlock.run({ + target: additionalTarget, + expectedSchemaRevision: additionalBefore.schemaRevision, + expectedRowRevision: additionalBefore.rowRevision, + expectedFieldRevision: additionalBefore.fieldRevision, + idempotencyKey: "additional-update", + operation: "update", + blockId: additionalBefore.blocks[0]!.id, + block: { kind: "paragraph", nfm: "Notes A changed" }, + }), + ); + const primaryAfter = await asOwner(() => + listBlocks.run({ target: state.target, limit: 100 }), + ); + expect(primaryAfter.blocks.map((block) => block.value.nfm)).toEqual([ + "Primary stays", + ]); + const [storedAdditional] = await getDb() + .select({ content: schema.documentBlockFieldContents.content }) + .from(schema.documentBlockFieldContents) + .where( + and( + eq( + schema.documentBlockFieldContents.documentId, + state.target.rowDocumentId, + ), + eq( + schema.documentBlockFieldContents.propertyId, + additional.definition.id, + ), + ), + ); + expect(storedAdditional?.content).toBe("Notes A changed\nNotes B"); + }); + + it("rejects a direct editor-body race without overwriting the newer body", async () => { + const state = await fixture("Before\nSibling"); + await getDb() + .update(schema.documents) + .set({ content: "Editor won\nSibling" }) + .where(eq(schema.documents.id, state.target.rowDocumentId)); + + await expect( + asOwner(() => + mutateBlock.run({ + ...envelope(state, "editor-race"), + operation: "update", + blockId: state.listed.blocks[0]!.id, + block: { kind: "paragraph", nfm: "Agent write" }, + }), + ), + ).rejects.toMatchObject({ errorCode: "BLOCK_IDENTITY_STALE" }); + + const [stored] = await getDb() + .select({ content: schema.documents.content }) + .from(schema.documents) + .where(eq(schema.documents.id, state.target.rowDocumentId)); + expect(stored?.content).toBe("Editor won\nSibling"); + }); + + it.skipIf(!TEST_DATABASE_URL.startsWith("postgres"))( + "serializes a direct title edit before validating the expected row revision", + async () => { + const state = await fixture("Before\nSibling"); + let mutation!: Promise; + await getDb().transaction(async (tx: any) => { + await tx + .update(schema.documents) + .set({ title: "UI title won", updatedAt: new Date().toISOString() }) + .where(eq(schema.documents.id, state.target.rowDocumentId)); + mutation = asOwner(() => + mutateBlock.run({ + ...envelope(state, "title-race"), + operation: "update", + blockId: state.listed.blocks[0]!.id, + block: { kind: "paragraph", nfm: "Agent write" }, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + await expect(mutation).rejects.toMatchObject({ + errorCode: "ROW_REVISION_CONFLICT", + }); + const after = await asOwner(() => + listBlocks.run({ target: state.target, limit: 100 }), + ); + expect(after.blocks.map((block) => block.value.nfm)).toEqual([ + "Before", + "Sibling", + ]); + const [document] = await getDb() + .select({ title: schema.documents.title }) + .from(schema.documents) + .where(eq(schema.documents.id, state.target.rowDocumentId)); + expect(document?.title).toBe("UI title won"); + }, + ); + + it("rejects stale existing and absent additional-field writes without clobbering", async () => { + const state = await fixture("Primary stays"); + const added = await asOwner(() => + configureProperty.run({ + documentId: state.databaseDocumentId, + databaseId: state.databaseId, + name: "Concurrent notes", + type: "blocks", + }), + ); + const additional = added.properties.find( + (property) => property.definition.name === "Concurrent notes", + )!; + await asOwner(() => + setProperty.run({ + databaseId: state.databaseId, + documentId: state.target.rowDocumentId, + propertyId: additional.definition.id, + value: "Agent read this\nSibling", + expectedBlocksFieldRevision: 0, + }), + ); + + await getDb() + .update(schema.documentBlockFieldContents) + .set({ content: "UI won\nSibling" }) + .where( + and( + eq( + schema.documentBlockFieldContents.documentId, + state.target.rowDocumentId, + ), + eq( + schema.documentBlockFieldContents.propertyId, + additional.definition.id, + ), + ), + ); + + await expect( + compareAndSwapAdditionalBlocksField({ + db: getDb(), + ownerEmail: OWNER, + documentId: state.target.rowDocumentId, + propertyId: additional.definition.id, + expectedContent: "Agent read this\nSibling", + expectedExists: true, + content: "Agent write\nSibling", + now: new Date().toISOString(), + }), + ).rejects.toMatchObject({ errorCode: "FIELD_REVISION_CONFLICT" }); + + const [stored] = await getDb() + .select({ content: schema.documentBlockFieldContents.content }) + .from(schema.documentBlockFieldContents) + .where( + and( + eq( + schema.documentBlockFieldContents.documentId, + state.target.rowDocumentId, + ), + eq( + schema.documentBlockFieldContents.propertyId, + additional.definition.id, + ), + ), + ); + expect(stored?.content).toBe("UI won\nSibling"); + + const addedAbsent = await asOwner(() => + configureProperty.run({ + documentId: state.databaseDocumentId, + databaseId: state.databaseId, + name: "Concurrent insert", + type: "blocks", + }), + ); + const absent = addedAbsent.properties.find( + (property) => property.definition.name === "Concurrent insert", + )!; + await asOwner(() => + setProperty.run({ + databaseId: state.databaseId, + documentId: state.target.rowDocumentId, + propertyId: absent.definition.id, + value: "UI created this", + expectedBlocksFieldRevision: 0, + }), + ); + + await expect( + compareAndSwapAdditionalBlocksField({ + db: getDb(), + ownerEmail: OWNER, + documentId: state.target.rowDocumentId, + propertyId: absent.definition.id, + expectedContent: "", + expectedExists: false, + content: "Agent insert", + now: new Date().toISOString(), + }), + ).rejects.toMatchObject({ errorCode: "FIELD_REVISION_CONFLICT" }); + + const [insertedByUi] = await getDb() + .select({ content: schema.documentBlockFieldContents.content }) + .from(schema.documentBlockFieldContents) + .where( + and( + eq( + schema.documentBlockFieldContents.documentId, + state.target.rowDocumentId, + ), + eq( + schema.documentBlockFieldContents.propertyId, + absent.definition.id, + ), + ), + ); + expect(insertedByUi?.content).toBe("UI created this"); + }); +}); diff --git a/templates/content/actions/list-content-database-blocks.ts b/templates/content/actions/list-content-database-blocks.ts new file mode 100644 index 0000000000..0258628c6c --- /dev/null +++ b/templates/content/actions/list-content-database-blocks.ts @@ -0,0 +1,31 @@ +import { defineAction } from "@agent-native/core"; +import { buildDeepLink } from "@agent-native/core/server"; + +import type { ContentDatabaseBlocksReadResult } from "../shared/database-block-actions.js"; +import { + listDatabaseBlocks, + listDatabaseBlocksSchema, +} from "./_database-block-actions.js"; + +export default defineAction({ + description: + "List stable blocks in one exact Content database row and Blocks property. Returns schema, row, and field revisions plus each block's supported individual operations.", + schema: listDatabaseBlocksSchema, + http: { method: "GET" }, + readOnly: true, + run: listDatabaseBlocks, + link: ({ result }) => { + const documentId = (result as ContentDatabaseBlocksReadResult | null) + ?.target.rowDocumentId; + if (!documentId) return null; + return { + url: buildDeepLink({ + app: "content", + view: "editor", + params: { documentId }, + }), + label: "Open database row", + view: "editor", + }; + }, +}); diff --git a/templates/content/actions/mutate-content-database-block.ts b/templates/content/actions/mutate-content-database-block.ts new file mode 100644 index 0000000000..945bfc719d --- /dev/null +++ b/templates/content/actions/mutate-content-database-block.ts @@ -0,0 +1,49 @@ +import { defineAction } from "@agent-native/core"; +import { writeAppState } from "@agent-native/core/application-state"; +import { buildDeepLink } from "@agent-native/core/server"; + +import type { ContentDatabaseBlockMutationResult } from "../shared/database-block-actions.js"; +import { + mutateDatabaseBlock, + mutateDatabaseBlockSchema, +} from "./_database-block-actions.js"; + +export default defineAction({ + description: + "Insert, update, upsert, delete, or reorder one stable block in an exact Content database Blocks field. Requires schema, row, and field revisions; preserves siblings and returns an idempotent verified receipt.", + schema: mutateDatabaseBlockSchema, + audit: { + recordInputs: false, + target: (args) => ({ + type: "document", + id: args.target.rowDocumentId, + visibility: "private", + }), + summary: (_args, result) => { + const receipt = (result as ContentDatabaseBlockMutationResult | null) + ?.receipt; + return receipt + ? `${receipt.outcome === "unchanged" ? "Checked" : "Mutated"} Content database block field ${receipt.target.propertyId}` + : "Mutated Content database block"; + }, + }, + run: async (args) => { + const result = await mutateDatabaseBlock(args); + await writeAppState("refresh-signal", { ts: Date.now() }); + return result; + }, + link: ({ result }) => { + const documentId = (result as ContentDatabaseBlockMutationResult | null) + ?.receipt.target.rowDocumentId; + if (!documentId) return null; + return { + url: buildDeepLink({ + app: "content", + view: "editor", + params: { documentId }, + }), + label: "Open database row", + view: "editor", + }; + }, +}); diff --git a/templates/content/changelog/2026-08-10-agents-can-safely-insert-update-upsert-delete-and-reorder-on.md b/templates/content/changelog/2026-08-10-agents-can-safely-insert-update-upsert-delete-and-reorder-on.md new file mode 100644 index 0000000000..2de2fdbd45 --- /dev/null +++ b/templates/content/changelog/2026-08-10-agents-can-safely-insert-update-upsert-delete-and-reorder-on.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-08-10 +--- + +Agents can safely insert, update, upsert, delete, and reorder one stable block in a database Blocks field. diff --git a/templates/content/docs/product/capabilities/content.object.block.md b/templates/content/docs/product/capabilities/content.object.block.md index 79a20f9dff..bd1bbf874f 100644 --- a/templates/content/docs/product/capabilities/content.object.block.md +++ b/templates/content/docs/product/capabilities/content.object.block.md @@ -22,7 +22,11 @@ proof_requirements: "Shared Action/UI editing, conflict, undo, and reload behavior", ] evidence: - ["shared/blocks-field-identity.ts", "actions/blocks-seeding.db.test.ts"] + [ + "shared/blocks-field-identity.ts", + "actions/blocks-seeding.db.test.ts", + "actions/content-database-block-actions.db.test.ts", + ] superseded_by: null last_reviewed: "2026-08-10" --- @@ -63,7 +67,7 @@ Given a Block reference in another Page, when an authorized reader opens it, the ## Current evidence -Database Blocks fields now have a field-scoped ordered identity sidecar with deterministic legacy IDs, persisted revisions, and bounded tombstone recovery. Deterministic tests cover editing, reorder, insertion, deletion, recovery, reload, and field independence. Agent-facing exact Block actions, reference/comment anchors, actor-aware history, and real-interface proof remain incomplete, so this is `in_progress`, not verified. +Database Blocks fields now have a field-scoped ordered identity sidecar with deterministic legacy IDs, persisted revisions, and bounded tombstone recovery. Exact database-row actions list stable Blocks and apply supported insert, update, upsert, delete, and same-parent reorder operations with schema, row, and field conflicts plus durable retry receipts. Deterministic tests cover sibling preservation, operation capabilities, deletion, recovery, reload, and field independence. Reference/comment anchors, actor-aware history, other Blocks-field owners, and real-interface proof remain incomplete, so this is `in_progress`, not verified. ## Proof plan diff --git a/templates/content/docs/product/capabilities/content.object.blocks-field.md b/templates/content/docs/product/capabilities/content.object.blocks-field.md index 34bf479261..8624b98ff8 100644 --- a/templates/content/docs/product/capabilities/content.object.blocks-field.md +++ b/templates/content/docs/product/capabilities/content.object.blocks-field.md @@ -29,6 +29,7 @@ evidence: "server/db/schema.ts", "actions/_blocks-field-identity.ts", "actions/blocks-seeding.db.test.ts", + "actions/content-database-block-actions.db.test.ts", ] superseded_by: null last_reviewed: "2026-08-10" @@ -70,7 +71,7 @@ Given a Page with two Blocks fields, when an authorized editor restores one fiel ## Current evidence -Primary and additional database Blocks properties now retain distinct field identities, ordered Block identities, and independent monotonic revisions around their existing Markdown stores. Export reports each field and its identity status without changing plain NFM. Comment/Discussion owners, attributable history, arbitrary restore, shared mutation actions, and real-interface proof remain incomplete, so this is `in_progress`, not verified. +Primary and additional database Blocks properties now retain distinct field identities, ordered Block identities, and independent monotonic revisions around their existing Markdown stores. Shared actions can list and mutate one exact database Blocks field with field-level compare-and-swap, sibling preservation, stable IDs, durable retry receipts, and verified read-back. Export reports each field and its identity status without changing plain NFM. Comment/Discussion owners, attributable history, arbitrary restore, and real-interface proof remain incomplete, so this is `in_progress`, not verified. ## Proof plan diff --git a/templates/content/parity/matrix.md b/templates/content/parity/matrix.md index 29c4dc14ff..a8ec1f56ae 100644 --- a/templates/content/parity/matrix.md +++ b/templates/content/parity/matrix.md @@ -2,33 +2,33 @@ 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`, `remove-database-items`, `duplicate-database-items`, `duplicate-database-item`, `migrate-content-database-rows`, `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`, `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.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/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._index.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`, `migrate-content-database-rows`, `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.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/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._index.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 6ec874e46c..026e234f4b 100644 --- a/templates/content/parity/matrix.ts +++ b/templates/content/parity/matrix.ts @@ -347,12 +347,14 @@ export const parityMatrix: ParityRow[] = [ durableEffect: "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.", uiImplementation: - "Row controls call row actions; selected-row duplicate/removal call bounded batch actions, while bounded whole-database schema-and-body migrations use one validated, receipt-backed action instead of many partial writes.", + "Row controls call row actions; the editor and agent share stable Blocks identities for one-block edits; selected-row duplicate/removal call bounded batch actions, while bounded whole-database schema-and-body migrations use one validated, receipt-backed action instead of many partial writes.", status: "action-backed", actions: [ "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", @@ -369,6 +371,7 @@ export const parityMatrix: ParityRow[] = [ "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", ], evalScenarioIds: ["database-bulk-row-reliability"], diff --git a/templates/content/server/agent-card.test.ts b/templates/content/server/agent-card.test.ts index 9a14540af6..4b21178fe8 100644 --- a/templates/content/server/agent-card.test.ts +++ b/templates/content/server/agent-card.test.ts @@ -2,7 +2,10 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { generateAgentCard } from "@agent-native/core/a2a"; -import { loadActionsFromStaticRegistry } from "@agent-native/core/server"; +import { + actionsToEngineTools, + loadActionsFromStaticRegistry, +} from "@agent-native/core/server"; import { generateActionRegistryForProject } from "@agent-native/core/vite"; import { describe, expect, it } from "vitest"; @@ -22,6 +25,8 @@ const REQUIRED_CONTENT_ACTIONS = [ "add-database-item", "update-database-item", "upsert-database-item-by-key", + "list-content-database-blocks", + "mutate-content-database-block", ]; const ACTION_REGISTRY_TEST_TIMEOUT_MS = 60_000; @@ -41,6 +46,9 @@ describe("content agent card", () => { "advertises content domain actions from the generated static registry", async () => { const actions = await loadContentActions(); + const engineToolNames = actionsToEngineTools(actions).map( + (tool) => tool.name, + ); const card = generateAgentCard( { name: "Content", @@ -60,6 +68,12 @@ describe("content agent card", () => { expect(card.skills.map((skill) => skill.id)).toEqual( expect.arrayContaining(REQUIRED_CONTENT_ACTIONS), ); + expect(engineToolNames).toEqual( + expect.arrayContaining([ + "list-content-database-blocks", + "mutate-content-database-block", + ]), + ); }, ACTION_REGISTRY_TEST_TIMEOUT_MS, ); diff --git a/templates/content/shared/blocks-field-identity.ts b/templates/content/shared/blocks-field-identity.ts index 29790f6687..e49fb055bf 100644 --- a/templates/content/shared/blocks-field-identity.ts +++ b/templates/content/shared/blocks-field-identity.ts @@ -4,6 +4,83 @@ export const BLOCKS_FIELD_IDENTITY_VERSION = 1; export const BLOCK_TOMBSTONE_REVISION_WINDOW = 20; export const MAX_BLOCK_TOMBSTONES_PER_FIELD = 500; +export const BLOCKS_FIELD_BLOCK_KINDS = [ + "paragraph", + "heading", + "horizontalRule", + "codeBlock", + "blockquote", + "bulletList", + "orderedList", + "listItem", + "taskList", + "taskItem", + "notionToggle", + "notionCallout", + "notionColumns", + "notionColumn", + "notionSyncedBlock", + "table", + "tableRow", + "tableHeader", + "tableCell", + "image", + "video", + "audio", + "notionBlockAtom", + "registryBlock", + "contentReference", + "localMdxComponent", +] as const; + +export type BlocksFieldBlockKind = (typeof BLOCKS_FIELD_BLOCK_KINDS)[number]; +export type BlocksFieldBlockOperation = + | "insert" + | "update" + | "upsert" + | "delete" + | "reorder"; + +const FULL_BLOCK_OPERATIONS = [ + "insert", + "update", + "upsert", + "delete", + "reorder", +] as const; +const ORDER_ONLY_BLOCK_OPERATIONS = ["delete", "reorder"] as const; + +export const BLOCKS_FIELD_OPERATION_CAPABILITIES: Readonly< + Record +> = { + paragraph: FULL_BLOCK_OPERATIONS, + heading: FULL_BLOCK_OPERATIONS, + horizontalRule: FULL_BLOCK_OPERATIONS, + codeBlock: FULL_BLOCK_OPERATIONS, + blockquote: FULL_BLOCK_OPERATIONS, + bulletList: [], + orderedList: [], + listItem: ORDER_ONLY_BLOCK_OPERATIONS, + taskList: [], + taskItem: ORDER_ONLY_BLOCK_OPERATIONS, + notionToggle: FULL_BLOCK_OPERATIONS, + notionCallout: FULL_BLOCK_OPERATIONS, + notionColumns: FULL_BLOCK_OPERATIONS, + notionColumn: FULL_BLOCK_OPERATIONS, + notionSyncedBlock: FULL_BLOCK_OPERATIONS, + table: FULL_BLOCK_OPERATIONS, + tableRow: [], + tableHeader: [], + tableCell: [], + image: FULL_BLOCK_OPERATIONS, + video: FULL_BLOCK_OPERATIONS, + audio: FULL_BLOCK_OPERATIONS, + notionBlockAtom: FULL_BLOCK_OPERATIONS, + registryBlock: FULL_BLOCK_OPERATIONS, + contentReference: FULL_BLOCK_OPERATIONS, + localMdxComponent: FULL_BLOCK_OPERATIONS, +}; + export type BlocksFieldIdentityStatus = "legacy" | "materialized" | "stale"; export interface BlocksFieldBlock { @@ -46,7 +123,7 @@ export interface StoredBlocksFieldIdentity { blocks: StoredBlocksFieldBlock[]; } -interface BlockSnapshot { +export interface BlocksFieldBlockSnapshot { path: string; parentPath: string | null; kind: string; @@ -57,34 +134,7 @@ interface BlockSnapshot { preferredId: string | null; } -const BLOCK_NODE_TYPES = new Set([ - "paragraph", - "heading", - "horizontalRule", - "codeBlock", - "blockquote", - "bulletList", - "orderedList", - "listItem", - "taskList", - "taskItem", - "notionToggle", - "notionCallout", - "notionColumns", - "notionColumn", - "notionSyncedBlock", - "table", - "tableRow", - "tableHeader", - "tableCell", - "image", - "video", - "audio", - "notionBlockAtom", - "registryBlock", - "contentReference", - "localMdxComponent", -]); +const BLOCK_NODE_TYPES = new Set(BLOCKS_FIELD_BLOCK_KINDS); const NON_ADDRESSABLE_NODE_TYPES = new Set([ "bulletList", @@ -133,8 +183,10 @@ function nodeMarkdown(node: PMNode): string { return docToNfm({ type: "doc", content: [node] }); } -function snapshotMarkdown(markdown: string): BlockSnapshot[] { - const snapshots: BlockSnapshot[] = []; +export function snapshotBlocksFieldMarkdown( + markdown: string, +): BlocksFieldBlockSnapshot[] { + const snapshots: BlocksFieldBlockSnapshot[] = []; const doc = nfmToDoc(markdown); function visit(nodes: PMNode[] | undefined, parentPath: string | null) { @@ -172,7 +224,7 @@ function snapshotMarkdown(markdown: string): BlockSnapshot[] { function deterministicBlockId( fieldId: string, - snapshot: Pick, + snapshot: Pick, ): string { return `block_${hashString( `${fieldId}\0${snapshot.path}\0${snapshot.kind}\0${snapshot.contentHash}`, @@ -225,7 +277,7 @@ export function legacyBlocksFieldIdentity(args: { markdown: string; }): BlocksFieldIdentity { const fieldId = blocksFieldId(args.documentId, args.propertyId); - const snapshots = snapshotMarkdown(args.markdown); + const snapshots = snapshotBlocksFieldMarkdown(args.markdown); const idByPath = new Map(); const usedIds = new Set(); const blocks: StoredBlocksFieldBlock[] = snapshots.map((snapshot) => { @@ -308,9 +360,10 @@ export function reconcileBlocksFieldIdentity(args: { previous: StoredBlocksFieldIdentity; markdown: string; createId: () => string; + preferredIdsByPath?: Readonly>; }): StoredBlocksFieldIdentity { const nextRevision = args.previous.revision + 1; - const snapshots = snapshotMarkdown(args.markdown); + const snapshots = snapshotBlocksFieldMarkdown(args.markdown); const previousLive = args.previous.blocks.filter( (block) => block.state === "live", ); @@ -320,6 +373,21 @@ export function reconcileBlocksFieldIdentity(args: { const matchedPrevious = new Set(); const assigned = new Map(); const usedIds = new Set(args.previous.blocks.map((block) => block.id)); + const assignedNextIds = new Set(); + + const previousLiveById = new Map( + previousLive.map((block, index) => [block.id, index]), + ); + for (let nextIndex = 0; nextIndex < snapshots.length; nextIndex++) { + const explicitId = args.preferredIdsByPath?.[snapshots[nextIndex]!.path]; + if (!explicitId) continue; + const previousIndex = previousLiveById.get(explicitId); + if (previousIndex === undefined || matchedPrevious.has(previousIndex)) { + continue; + } + matchedPrevious.add(previousIndex); + assigned.set(nextIndex, previousLive[previousIndex]!); + } const previousExact = uniqueIndexByKey( previousLive, @@ -440,8 +508,9 @@ export function reconcileBlocksFieldIdentity(args: { const recoveredIds = new Set(); const idByPath = new Map(); const nextBlocks = snapshots.map((snapshot, nextIndex) => { + const explicitId = args.preferredIdsByPath?.[snapshot.path]; let previous = assigned.get(nextIndex); - if (!previous) { + if (!previous && !explicitId) { const recoveredIndex = recoverable.get( `${snapshot.kind}\0${snapshot.contentHash}`, ); @@ -453,16 +522,24 @@ export function reconcileBlocksFieldIdentity(args: { } } } + if (explicitId && usedIds.has(explicitId) && explicitId !== previous?.id) { + throw new Error(`Preferred Block ID is already reserved: ${explicitId}`); + } let id = + explicitId ?? previous?.id ?? fieldScopedBlockId( args.previous.fieldId, snapshot.preferredId ?? args.createId(), ); - while (usedIds.has(id) && id !== previous?.id) { + while ( + assignedNextIds.has(id) || + (!explicitId && usedIds.has(id) && id !== previous?.id) + ) { id = fieldScopedBlockId(args.previous.fieldId, args.createId()); } usedIds.add(id); + assignedNextIds.add(id); idByPath.set(snapshot.path, id); return { id, @@ -537,7 +614,7 @@ export function materializeLegacyBlocksFieldIdentity(args: { markdown: string; }): StoredBlocksFieldIdentity { const publicState = legacyBlocksFieldIdentity(args); - const snapshots = snapshotMarkdown(args.markdown); + const snapshots = snapshotBlocksFieldMarkdown(args.markdown); return { fieldId: publicState.fieldId, revision: 0, diff --git a/templates/content/shared/database-block-actions.ts b/templates/content/shared/database-block-actions.ts new file mode 100644 index 0000000000..a24d343cef --- /dev/null +++ b/templates/content/shared/database-block-actions.ts @@ -0,0 +1,82 @@ +import type { ContentDatabaseMutationTarget } from "./api.js"; +import type { + BlocksFieldBlockKind, + BlocksFieldBlockOperation, + BlocksFieldIdentityStatus, +} from "./blocks-field-identity.js"; + +export interface ContentDatabaseBlockTarget extends ContentDatabaseMutationTarget { + itemId: string; + rowDocumentId: string; + propertyId: string; +} + +export interface ContentDatabaseBlockValue { + format: "nfm"; + nfm: string; +} + +export interface ContentDatabaseBlock { + id: string; + parentId: string | null; + kind: BlocksFieldBlockKind; + index: number; + addressable: boolean; + value: ContentDatabaseBlockValue; + supportedOperations: readonly BlocksFieldBlockOperation[]; + degraded: boolean; +} + +export interface ContentDatabaseBlocksReadResult { + target: ContentDatabaseBlockTarget; + rowLink: { urlPath: string; label: string }; + schemaRevision: string; + rowRevision: string; + fieldRevision: number; + identityStatus: BlocksFieldIdentityStatus; + total: number; + order: string[]; + blocks: ContentDatabaseBlock[]; + page: { offset: number; limit: number; nextCursor: string | null }; +} + +export type ContentDatabaseBlockMutationOperation = + | "insert" + | "update" + | "upsert" + | "delete" + | "reorder"; + +export interface ContentDatabaseBlockMutationReceipt { + receiptId: string; + operation: ContentDatabaseBlockMutationOperation; + outcome: "inserted" | "updated" | "deleted" | "reordered" | "unchanged"; + target: ContentDatabaseBlockTarget; + rowLink: { urlPath: string; label: string }; + schemaRevision: string; + idempotency: { + key: string; + result: "applied" | "replayed"; + payloadDigest: string; + }; + revisions: { + row: { before: string; after: string }; + field: { before: number; after: number }; + }; + affected: { + blockIds: string[]; + deletedBlockIds: string[]; + order: string[]; + }; + readback: { + verified: true; + fieldRevision: number; + contentHash: string; + order: string[]; + blocks: ContentDatabaseBlock[]; + }; +} + +export interface ContentDatabaseBlockMutationResult { + receipt: ContentDatabaseBlockMutationReceipt; +} diff --git a/templates/content/shared/database-block-mutations.spec.ts b/templates/content/shared/database-block-mutations.spec.ts new file mode 100644 index 0000000000..4604931e84 --- /dev/null +++ b/templates/content/shared/database-block-mutations.spec.ts @@ -0,0 +1,497 @@ +import { describe, expect, it } from "vitest"; + +import { + BLOCKS_FIELD_BLOCK_KINDS, + BLOCKS_FIELD_OPERATION_CAPABILITIES, + exposeBlocksFieldIdentity, + legacyBlocksFieldIdentity, + materializeLegacyBlocksFieldIdentity, + reconcileBlocksFieldIdentity, +} from "./blocks-field-identity.js"; +import { mutateBlocksFieldDocument } from "./database-block-mutations.js"; + +function identity(markdown: string) { + return legacyBlocksFieldIdentity({ + documentId: "document-1", + propertyId: "property-1", + markdown, + }); +} + +function persisted(markdown: string) { + return materializeLegacyBlocksFieldIdentity({ + documentId: "document-1", + propertyId: "property-1", + markdown, + }); +} + +describe("individual Blocks-field document mutations", () => { + const fullyMutableKinds = [ + ["paragraph", "Paragraph"], + ["heading", "# Heading"], + ["horizontalRule", "---"], + ["codeBlock", "```ts\nconst value = 1;\n```"], + ["blockquote", "> Quote"], + [ + "notionToggle", + "
\nToggle\n\tChild\n
", + ], + ["notionCallout", "\n\tCallout\n"], + [ + "notionColumns", + "\n\t\n\t\tColumn\n\t\n", + ], + ["notionColumn", "\n\tColumn\n"], + [ + "notionSyncedBlock", + '\n\tShared\n', + ], + [ + "table", + '\n\n\n\n
Header
', + ], + ["image", "![Diagram](https://example.com/image.png)"], + ["video", ''], + ["audio", ''], + ["notionBlockAtom", 'Page'], + [ + "registryBlock", + '', + ], + [ + "contentReference", + '', + ], + ["localMdxComponent", ''], + ] as const; + + it("declares an explicit operation matrix for every indexed block kind", () => { + expect(Object.keys(BLOCKS_FIELD_OPERATION_CAPABILITIES).sort()).toEqual( + [...BLOCKS_FIELD_BLOCK_KINDS].sort(), + ); + expect(BLOCKS_FIELD_OPERATION_CAPABILITIES.paragraph).toEqual([ + "insert", + "update", + "upsert", + "delete", + "reorder", + ]); + expect(BLOCKS_FIELD_OPERATION_CAPABILITIES.listItem).toEqual([ + "delete", + "reorder", + ]); + expect(BLOCKS_FIELD_OPERATION_CAPABILITIES.tableCell).toEqual([]); + }); + + it("inserts one block without changing either sibling ID", () => { + const markdown = "Alpha\nBeta"; + const before = identity(markdown); + const changed = mutateBlocksFieldDocument({ + markdown, + identity: before, + mutation: { + operation: "insert", + block: { kind: "paragraph", nfm: "Middle" }, + position: { placement: "before", anchorBlockId: before.blocks[1]!.id }, + }, + insertedBlockId: "block_requested", + }); + expect(changed.markdown).toBe("Alpha\nMiddle\nBeta"); + const next = reconcileBlocksFieldIdentity({ + documentId: "document-1", + propertyId: "property-1", + previous: persisted(markdown), + markdown: changed.markdown, + preferredIdsByPath: changed.preferredIdsByPath, + createId: () => "unexpected", + }); + expect( + next.blocks + .filter((block) => block.state === "live") + .map((block) => block.id), + ).toEqual([before.blocks[0]!.id, "block_requested", before.blocks[1]!.id]); + }); + + it("keeps exact IDs when inserting among indistinguishable siblings", () => { + const markdown = "Same\nSame"; + const before = identity(markdown); + const changed = mutateBlocksFieldDocument({ + markdown, + identity: before, + mutation: { + operation: "insert", + block: { kind: "paragraph", nfm: "Same" }, + position: { placement: "before", anchorBlockId: before.blocks[1]!.id }, + }, + insertedBlockId: "block_exact_middle", + }); + const next = reconcileBlocksFieldIdentity({ + documentId: "document-1", + propertyId: "property-1", + previous: persisted(markdown), + markdown: changed.markdown, + preferredIdsByPath: changed.preferredIdsByPath, + createId: () => "unexpected", + }); + expect( + next.blocks + .filter((block) => block.state === "live") + .map((block) => block.id), + ).toEqual([ + before.blocks[0]!.id, + "block_exact_middle", + before.blocks[1]!.id, + ]); + }); + + it("updates one block by ID and preserves its sibling bytes and IDs", () => { + const markdown = "Alpha\nBeta"; + const before = identity(markdown); + const changed = mutateBlocksFieldDocument({ + markdown, + identity: before, + mutation: { + operation: "update", + blockId: before.blocks[0]!.id, + block: { kind: "paragraph", nfm: "Completely rewritten" }, + }, + }); + expect(changed.markdown).toBe("Completely rewritten\nBeta"); + expect(changed.preferredIdsByPath).toMatchObject({ + "0": before.blocks[0]!.id, + "1": before.blocks[1]!.id, + }); + }); + + it("deletes one identified block and reports only its subtree candidates", () => { + const markdown = "Alpha\nBeta\nGamma"; + const before = identity(markdown); + const changed = mutateBlocksFieldDocument({ + markdown, + identity: before, + mutation: { operation: "delete", blockId: before.blocks[1]!.id }, + }); + expect(changed.markdown).toBe("Alpha\nGamma"); + expect(changed.deletedCandidateIds).toEqual([before.blocks[1]!.id]); + }); + + it("deletes a container as one operation while identifying its full subtree", () => { + const markdown = "\n\tChild\n\nSibling"; + const before = identity(markdown); + const callout = before.blocks.find( + (block) => block.kind === "notionCallout", + )!; + const child = before.blocks.find((block) => block.parentId === callout.id)!; + const changed = mutateBlocksFieldDocument({ + markdown, + identity: before, + mutation: { operation: "delete", blockId: callout.id }, + }); + expect(changed.markdown).toBe("Sibling"); + expect(changed.deletedCandidateIds).toEqual([callout.id, child.id]); + }); + + it("preserves an unchanged descendant ID when updating its container", () => { + const markdown = "\n\tChild\n\nSibling"; + const stored = persisted(markdown); + const before = exposeBlocksFieldIdentity(stored, markdown); + const callout = before.blocks.find( + (block) => block.kind === "notionCallout", + )!; + const child = before.blocks.find((block) => block.parentId === callout.id)!; + const changed = mutateBlocksFieldDocument({ + markdown, + identity: before, + mutation: { + operation: "update", + blockId: callout.id, + block: { + kind: "notionCallout", + nfm: '\n\tChild\n', + }, + }, + }); + const next = reconcileBlocksFieldIdentity({ + documentId: "document-1", + propertyId: "property-1", + previous: stored, + markdown: changed.markdown, + preferredIdsByPath: changed.preferredIdsByPath, + createId: () => "unexpected", + }); + expect(next.blocks.find((block) => block.markdown === "Child")?.id).toBe( + child.id, + ); + }); + + it("rejects leaf blocks as insertion parents before serialization", () => { + const leafKinds = [ + ["paragraph", "Paragraph"], + ["heading", "# Heading"], + ["horizontalRule", "---"], + ["codeBlock", "```\ncode\n```"], + ["image", "![image](https://example.com/image.png)"], + ["video", ''], + ["audio", ''], + ["notionBlockAtom", 'Page'], + ["registryBlock", ''], + ["contentReference", ''], + ["localMdxComponent", ''], + ] as const; + + for (const [kind, markdown] of leafKinds) { + const before = identity(markdown); + const parent = before.blocks.find((block) => block.kind === kind)!; + expect(() => + mutateBlocksFieldDocument({ + markdown, + identity: before, + mutation: { + operation: "insert", + block: { kind: "paragraph", nfm: "Nested" }, + position: { placement: "end", parentBlockId: parent.id }, + }, + insertedBlockId: `nested-${kind}`, + }), + ).toThrow(`not valid inside ${kind}`); + } + }); + + it("keeps every deleted subtree ID tombstoned after an identical fresh insert", () => { + const insertedMarkdown = "\n\tChild\n"; + const markdown = `${insertedMarkdown}\nSibling`; + let stored = persisted(markdown); + const before = exposeBlocksFieldIdentity(stored, markdown); + const oldIds = before.blocks.map((block) => block.id); + const deleted = mutateBlocksFieldDocument({ + markdown, + identity: before, + mutation: { operation: "delete", blockId: before.blocks[0]!.id }, + }); + stored = reconcileBlocksFieldIdentity({ + documentId: "document-1", + propertyId: "property-1", + previous: stored, + markdown: deleted.markdown, + preferredIdsByPath: deleted.preferredIdsByPath, + createId: () => "unexpected-delete-id", + }); + const deletedIdentity = exposeBlocksFieldIdentity(stored, deleted.markdown); + let descendantIndex = 0; + const inserted = mutateBlocksFieldDocument({ + markdown: deleted.markdown, + identity: deletedIdentity, + mutation: { + operation: "insert", + block: { kind: "notionCallout", nfm: insertedMarkdown }, + position: { placement: "start" }, + }, + insertedBlockId: "fresh-callout", + createInsertedDescendantId: () => `fresh-child-${descendantIndex++}`, + }); + stored = reconcileBlocksFieldIdentity({ + documentId: "document-1", + propertyId: "property-1", + previous: stored, + markdown: inserted.markdown, + preferredIdsByPath: inserted.preferredIdsByPath, + createId: () => "unexpected-insert-id", + }); + + const liveIds = stored.blocks + .filter((block) => block.state === "live") + .map((block) => block.id); + const tombstoneIds = stored.blocks + .filter((block) => block.state === "deleted") + .map((block) => block.id); + expect(liveIds).toEqual( + expect.arrayContaining(["fresh-callout", "fresh-child-0"]), + ); + expect(liveIds).not.toEqual(expect.arrayContaining(oldIds.slice(0, 2))); + expect(tombstoneIds).toEqual(expect.arrayContaining(oldIds.slice(0, 2))); + }); + + it("reorders within one parent while retaining every stable ID", () => { + const markdown = "Alpha\nBeta\nGamma"; + const before = identity(markdown); + const changed = mutateBlocksFieldDocument({ + markdown, + identity: before, + mutation: { + operation: "reorder", + blockId: before.blocks[2]!.id, + position: { placement: "before", anchorBlockId: before.blocks[0]!.id }, + }, + }); + expect(changed.markdown).toBe("Gamma\nAlpha\nBeta"); + expect(Object.values(changed.preferredIdsByPath).sort()).toEqual( + before.blocks.map((block) => block.id).sort(), + ); + }); + + it("updates and repositions an existing upsert while retaining its stable ID", () => { + const markdown = "Alpha\nBeta\nGamma"; + const before = identity(markdown); + const beta = before.blocks[1]!; + const gamma = before.blocks[2]!; + const changed = mutateBlocksFieldDocument({ + markdown, + identity: before, + mutation: { + operation: "upsert", + blockId: beta.id, + block: { kind: "paragraph", nfm: "Beta moved" }, + position: { placement: "after", anchorBlockId: gamma.id }, + }, + }); + + expect(changed.markdown).toBe("Alpha\nGamma\nBeta moved"); + const stored = reconcileBlocksFieldIdentity({ + documentId: "document-1", + propertyId: "property-1", + previous: persisted(markdown), + markdown: changed.markdown, + preferredIdsByPath: changed.preferredIdsByPath, + createId: () => "unexpected-upsert-id", + }); + expect( + exposeBlocksFieldIdentity(stored, changed.markdown).blocks.map( + (block) => block.id, + ), + ).toEqual([before.blocks[0]!.id, gamma.id, beta.id]); + }); + + it("rejects unsupported structural updates and kind conversion", () => { + const list = identity("- one\n- two"); + const listItem = list.blocks.find((block) => block.kind === "listItem")!; + expect(() => + mutateBlocksFieldDocument({ + markdown: "- one\n- two", + identity: list, + mutation: { + operation: "update", + blockId: listItem.id, + block: { kind: "listItem", nfm: "- changed" }, + }, + }), + ).toThrow('Block kind "listItem" does not support update.'); + + const paragraph = identity("Alpha"); + expect(() => + mutateBlocksFieldDocument({ + markdown: "Alpha", + identity: paragraph, + mutation: { + operation: "update", + blockId: paragraph.blocks[0]!.id, + block: { kind: "heading", nfm: "# Alpha" }, + }, + }), + ).toThrow("cannot change block kind"); + }); + + it("rejects cross-parent reorder without changing either container", () => { + const markdown = + "\n\tFirst child\n\n\n\tSecond child\n"; + const before = identity(markdown); + const children = before.blocks.filter((block) => block.parentId !== null); + expect(() => + mutateBlocksFieldDocument({ + markdown, + identity: before, + mutation: { + operation: "reorder", + blockId: children[0]!.id, + position: { placement: "before", anchorBlockId: children[1]!.id }, + }, + }), + ).toThrow("Cross-parent block reorder is not supported."); + }); + + it.each(fullyMutableKinds)( + "executes every declared individual operation for live %s blocks", + (kind, markdown) => { + const fieldMarkdown = + kind === "notionColumn" + ? "\n\t\n\t\tFirst\n\t\n\t\n\t\tSecond\n\t\n" + : `${markdown}\nTail`; + let stored = persisted(fieldMarkdown); + let before = exposeBlocksFieldIdentity(stored, fieldMarkdown); + const block = before.blocks.find((candidate) => candidate.kind === kind); + expect( + block, + `${kind} must be indexed by the identity contract`, + ).toBeTruthy(); + + const insertedId = `inserted_${kind}`; + let generatedId = 0; + const createId = () => `generated_${kind}_${generatedId++}`; + const inserted = mutateBlocksFieldDocument({ + markdown: fieldMarkdown, + identity: before, + mutation: { + operation: "insert", + block: { kind, nfm: markdown }, + position: { placement: "after", anchorBlockId: block!.id }, + }, + insertedBlockId: insertedId, + createInsertedDescendantId: createId, + }); + expect(inserted.changed).toBe(true); + stored = reconcileBlocksFieldIdentity({ + documentId: "document-1", + propertyId: "property-1", + previous: stored, + markdown: inserted.markdown, + preferredIdsByPath: inserted.preferredIdsByPath, + createId, + }); + before = exposeBlocksFieldIdentity(stored, inserted.markdown); + expect( + before.blocks.some((candidate) => candidate.id === insertedId), + ).toBe(true); + + for (const operation of ["update", "upsert"] as const) { + expect(() => + mutateBlocksFieldDocument({ + markdown: inserted.markdown, + identity: before, + mutation: { + operation, + blockId: insertedId, + block: { kind, nfm: markdown }, + }, + }), + ).not.toThrow(); + } + + const reordered = mutateBlocksFieldDocument({ + markdown: inserted.markdown, + identity: before, + mutation: { + operation: "reorder", + blockId: insertedId, + position: { placement: "before", anchorBlockId: block!.id }, + }, + }); + expect(reordered.changed).toBe(true); + stored = reconcileBlocksFieldIdentity({ + documentId: "document-1", + propertyId: "property-1", + previous: stored, + markdown: reordered.markdown, + preferredIdsByPath: reordered.preferredIdsByPath, + createId, + }); + before = exposeBlocksFieldIdentity(stored, reordered.markdown); + const deleted = mutateBlocksFieldDocument({ + markdown: reordered.markdown, + identity: before, + mutation: { operation: "delete", blockId: insertedId }, + }); + expect(deleted.changed).toBe(true); + expect(deleted.deletedCandidateIds).toContain(insertedId); + }, + ); +}); diff --git a/templates/content/shared/database-block-mutations.ts b/templates/content/shared/database-block-mutations.ts new file mode 100644 index 0000000000..d6c9a5791d --- /dev/null +++ b/templates/content/shared/database-block-mutations.ts @@ -0,0 +1,366 @@ +import { + BLOCKS_FIELD_BLOCK_KINDS, + BLOCKS_FIELD_OPERATION_CAPABILITIES, + type BlocksFieldBlockKind, + type BlocksFieldBlockOperation, + type BlocksFieldIdentity, +} from "./blocks-field-identity.js"; +import { docToNfm, nfmToDoc, type PMDoc, type PMNode } from "./nfm.js"; + +type Placement = + | { placement: "start" | "end"; parentBlockId?: string | null } + | { placement: "before" | "after"; anchorBlockId: string }; + +export type BlockDocumentMutation = + | { + operation: "insert"; + block: { kind: BlocksFieldBlockKind; nfm: string }; + position: Placement; + } + | { + operation: "update" | "upsert"; + blockId: string; + block: { kind: BlocksFieldBlockKind; nfm: string }; + position?: Placement; + } + | { operation: "delete"; blockId: string } + | { operation: "reorder"; blockId: string; position: Placement }; + +interface NodeRef { + node: PMNode; + nodes: PMNode[]; + nodeIndex: number; + path: string; + parentPath: string | null; +} + +export interface BlockDocumentMutationResult { + markdown: string; + preferredIdsByPath: Record; + requestedBlockId: string | null; + deletedCandidateIds: string[]; + changed: boolean; +} + +const BLOCK_KIND_SET = new Set(BLOCKS_FIELD_BLOCK_KINDS); + +function collectNodeRefs(doc: PMDoc): NodeRef[] { + const refs: NodeRef[] = []; + const visit = (nodes: PMNode[] | undefined, parentPath: string | null) => { + let blockPosition = 0; + for (let nodeIndex = 0; nodeIndex < (nodes?.length ?? 0); nodeIndex++) { + const node = nodes![nodeIndex]!; + if (!BLOCK_KIND_SET.has(node.type)) continue; + const path = + parentPath === null + ? `${blockPosition}` + : `${parentPath}.${blockPosition}`; + refs.push({ node, nodes: nodes!, nodeIndex, path, parentPath }); + visit(node.content, path); + blockPosition++; + } + }; + visit(doc.content, null); + return refs; +} + +function indexIdentity(markdown: string, identity: BlocksFieldIdentity) { + const doc = nfmToDoc(markdown); + const refs = collectNodeRefs(doc); + if (refs.length !== identity.blocks.length) { + throw new Error("Blocks identity does not match the current field body."); + } + const byId = new Map(); + const idByNode = new Map(); + refs.forEach((ref, index) => { + const block = identity.blocks[index]!; + if (block.kind !== ref.node.type) { + throw new Error( + "Blocks identity kind does not match the current field body.", + ); + } + byId.set(block.id, ref); + idByNode.set(ref.node, block.id); + }); + return { doc, byId, idByNode }; +} + +function parsedBlock(kind: BlocksFieldBlockKind, nfm: string): PMNode { + const doc = nfmToDoc(nfm); + if (doc.content.length !== 1 || doc.content[0]?.type !== kind) { + throw new Error( + `Block value must be canonical NFM containing exactly one top-level ${kind} block.`, + ); + } + return doc.content[0]; +} + +function requireOperation( + kind: BlocksFieldBlockKind, + operation: BlocksFieldBlockOperation, +) { + const supported = BLOCKS_FIELD_OPERATION_CAPABILITIES[ + kind + ] as readonly string[]; + if (!supported.includes(operation as string)) { + throw new Error(`Block kind "${kind}" does not support ${operation}.`); + } +} + +function destination( + position: Placement, + byId: Map, + doc: PMDoc, +): { nodes: PMNode[]; index: number; parentPath: string | null } { + if ("anchorBlockId" in position) { + const anchor = byId.get(position.anchorBlockId); + if (!anchor) throw new Error("Anchor block not found."); + return { + nodes: anchor.nodes, + index: anchor.nodeIndex + (position.placement === "after" ? 1 : 0), + parentPath: anchor.parentPath, + }; + } + if (!position.parentBlockId) { + return { + nodes: doc.content, + index: position.placement === "start" ? 0 : doc.content.length, + parentPath: null, + }; + } + const parent = byId.get(position.parentBlockId); + if (!parent) throw new Error("Parent block not found."); + const nodes = (parent.node.content ??= []); + return { + nodes, + index: position.placement === "start" ? 0 : nodes.length, + parentPath: parent.path, + }; +} + +function assertCompatibleParent( + parentPath: string | null, + kind: BlocksFieldBlockKind, + byId: Map, +) { + const parent = [...byId.values()].find( + (ref) => ref.path === parentPath, + )?.node; + const parentKind = parent?.type; + const generalContainerKinds = new Set([ + "blockquote", + "listItem", + "taskItem", + "notionToggle", + "notionCallout", + "notionColumn", + "notionSyncedBlock", + "tableHeader", + "tableCell", + ]); + const valid = + parentKind === undefined + ? ![ + "listItem", + "taskItem", + "notionColumn", + "tableRow", + "tableHeader", + "tableCell", + ].includes(kind) + : parentKind === "bulletList" || parentKind === "orderedList" + ? kind === "listItem" + : parentKind === "taskList" + ? kind === "taskItem" + : parentKind === "notionColumns" + ? kind === "notionColumn" + : parentKind === "table" + ? kind === "tableRow" + : parentKind === "tableRow" + ? kind === "tableHeader" || kind === "tableCell" + : generalContainerKinds.has(parentKind) && + ![ + "listItem", + "taskItem", + "notionColumn", + "tableRow", + "tableHeader", + "tableCell", + ].includes(kind); + if (!valid) { + throw new Error( + `Block kind "${kind}" is not valid inside ${parentKind ?? "the field root"}.`, + ); + } +} + +function repositionNode(args: { + blockId: string; + current: NodeRef; + node: PMNode; + position: Placement; + byId: Map; + doc: PMDoc; +}) { + if ( + "anchorBlockId" in args.position && + args.position.anchorBlockId === args.blockId + ) { + throw new Error("A block cannot be reordered relative to itself."); + } + const target = destination(args.position, args.byId, args.doc); + if (target.parentPath !== args.current.parentPath) { + throw new Error("Cross-parent block reorder is not supported."); + } + args.current.nodes.splice(args.current.nodeIndex, 1); + const targetIndex = + "anchorBlockId" in args.position + ? (() => { + const anchor = args.byId.get(args.position.anchorBlockId); + if (!anchor || anchor.nodes !== target.nodes) { + throw new Error("Reorder anchor is outside the current parent."); + } + const anchorIndex = target.nodes.indexOf(anchor.node); + return anchorIndex + (args.position.placement === "after" ? 1 : 0); + })() + : args.position.placement === "start" + ? 0 + : target.nodes.length; + target.nodes.splice(targetIndex, 0, args.node); +} + +function preferredIds( + doc: PMDoc, + idByNode: Map, + requestedNode?: PMNode, + requestedId?: string, + createInsertedDescendantId?: () => string, +) { + const requestedDescendants = new Set(); + const collectRequestedDescendants = (nodes: PMNode[] | undefined) => { + for (const node of nodes ?? []) { + requestedDescendants.add(node); + collectRequestedDescendants(node.content); + } + }; + if (requestedId && requestedNode) { + collectRequestedDescendants(requestedNode.content); + } + return Object.fromEntries( + collectNodeRefs(doc).flatMap((ref) => { + const id = + idByNode.get(ref.node) ?? + (ref.node === requestedNode + ? requestedId + : requestedDescendants.has(ref.node) + ? createInsertedDescendantId?.() + : undefined); + if ( + requestedDescendants.has(ref.node) && + !id && + !createInsertedDescendantId + ) { + throw new Error( + "Inserting a nested block requires fresh descendant Block IDs.", + ); + } + return id ? [[ref.path, id]] : []; + }), + ); +} + +export function mutateBlocksFieldDocument(args: { + markdown: string; + identity: BlocksFieldIdentity; + mutation: BlockDocumentMutation; + insertedBlockId?: string; + createInsertedDescendantId?: () => string; +}): BlockDocumentMutationResult { + const indexed = indexIdentity(args.markdown, args.identity); + const { doc, byId, idByNode } = indexed; + const before = docToNfm(doc); + let requestedNode: PMNode | undefined; + let requestedBlockId: string | null = null; + let deletedCandidateIds: string[] = []; + + if (args.mutation.operation === "insert") { + requireOperation(args.mutation.block.kind, "insert"); + requestedNode = parsedBlock( + args.mutation.block.kind, + args.mutation.block.nfm, + ); + const target = destination(args.mutation.position, byId, doc); + assertCompatibleParent(target.parentPath, args.mutation.block.kind, byId); + target.nodes.splice(target.index, 0, requestedNode); + } else { + const current = byId.get(args.mutation.blockId); + if (!current) throw new Error("Block not found."); + const currentKind = current.node.type as BlocksFieldBlockKind; + requireOperation(currentKind, args.mutation.operation); + requestedBlockId = args.mutation.blockId; + + if ( + args.mutation.operation === "update" || + args.mutation.operation === "upsert" + ) { + if (args.mutation.block.kind !== currentKind) { + throw new Error("Individual block mutations cannot change block kind."); + } + requestedNode = parsedBlock(currentKind, args.mutation.block.nfm); + current.nodes[current.nodeIndex] = requestedNode; + current.node = requestedNode; + idByNode.set(requestedNode, args.mutation.blockId); + if (args.mutation.operation === "upsert" && args.mutation.position) { + repositionNode({ + blockId: args.mutation.blockId, + current, + node: requestedNode, + position: args.mutation.position, + byId, + doc, + }); + } + } else if (args.mutation.operation === "delete") { + deletedCandidateIds = args.identity.blocks + .filter((block) => { + const ref = byId.get(block.id); + return ( + ref?.path === current.path || + ref?.path.startsWith(`${current.path}.`) + ); + }) + .map((block) => block.id); + current.nodes.splice(current.nodeIndex, 1); + } else if (args.mutation.operation === "reorder") { + repositionNode({ + blockId: args.mutation.blockId, + current, + node: current.node, + position: args.mutation.position, + byId, + doc, + }); + } + } + + const markdown = docToNfm(doc); + const preferredIdsByPath = preferredIds( + doc, + idByNode, + requestedNode, + args.insertedBlockId, + args.createInsertedDescendantId, + ); + const identityOrderChanged = collectNodeRefs(doc).some( + (ref, index) => + preferredIdsByPath[ref.path] !== undefined && + preferredIdsByPath[ref.path] !== args.identity.blocks[index]?.id, + ); + return { + markdown, + preferredIdsByPath, + requestedBlockId, + deletedCandidateIds, + changed: markdown !== before || identityOrderChanged, + }; +}