From 4516c29143db503ee9af96ced7b1a03cd682f5cf Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:07:20 -0400 Subject: [PATCH 01/16] feat(content): preserve database block identity --- .../content/actions/_blocks-field-identity.ts | 350 ++++++++++++ templates/content/actions/_property-utils.ts | 122 +++- .../content/actions/blocks-seeding.db.test.ts | 273 +++++++++ .../actions/configure-document-property.ts | 5 + .../actions/delete-document-property.ts | 5 + templates/content/actions/delete-document.ts | 4 + templates/content/actions/export-document.ts | 35 ++ ...database-rows.postgres.integration.test.ts | 57 ++ .../content/actions/remove-database-items.ts | 10 + .../content/actions/set-document-property.ts | 39 +- templates/content/actions/update-document.ts | 33 ++ .../components/editor/DocumentBlockFields.tsx | 23 +- .../editor/useBlockFieldEditor.test.tsx | 18 +- ...s-now-keep-logical-block-identity-throu.md | 6 + .../capabilities/content.object.block.md | 9 +- .../content.object.blocks-field.md | 13 +- .../content/docs/product/encyclopedia.md | 8 +- templates/content/server/db/schema.ts | 61 ++ templates/content/server/plugins/db.ts | 43 ++ templates/content/shared/api.ts | 3 + .../shared/blocks-field-identity.spec.ts | 224 ++++++++ .../content/shared/blocks-field-identity.ts | 525 ++++++++++++++++++ .../content/shared/document-export.spec.ts | 51 ++ templates/content/shared/document-export.ts | 33 +- 24 files changed, 1904 insertions(+), 46 deletions(-) create mode 100644 templates/content/actions/_blocks-field-identity.ts create mode 100644 templates/content/changelog/2026-08-10-database-blocks-fields-now-keep-logical-block-identity-throu.md create mode 100644 templates/content/shared/blocks-field-identity.spec.ts create mode 100644 templates/content/shared/blocks-field-identity.ts diff --git a/templates/content/actions/_blocks-field-identity.ts b/templates/content/actions/_blocks-field-identity.ts new file mode 100644 index 0000000000..ea6e50850a --- /dev/null +++ b/templates/content/actions/_blocks-field-identity.ts @@ -0,0 +1,350 @@ +import { and, asc, eq, inArray } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import { + blocksContentHash, + blocksFieldId, + exposeBlocksFieldIdentity, + legacyBlocksFieldIdentity, + materializeLegacyBlocksFieldIdentity, + reconcileBlocksFieldIdentity, + type BlocksFieldIdentity, + type StoredBlocksFieldIdentity, +} from "../shared/blocks-field-identity.js"; + +type ContentDb = ReturnType; + +function nanoid(size = 12): string { + const chars = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + let id = ""; + const bytes = crypto.getRandomValues(new Uint8Array(size)); + for (const byte of bytes) id += chars[byte % chars.length]; + return id; +} + +function groups(values: T[], size: number): T[][] { + const result: T[][] = []; + for (let index = 0; index < values.length; index += size) { + result.push(values.slice(index, index + size)); + } + return result; +} + +async function loadStoredIdentity( + db: ContentDb, + fieldId: string, +): Promise { + const [field] = await db + .select() + .from(schema.documentBlockFields) + .where(eq(schema.documentBlockFields.id, fieldId)); + if (!field) return null; + const blocks = await db + .select() + .from(schema.documentBlocks) + .where(eq(schema.documentBlocks.fieldId, fieldId)) + .orderBy(asc(schema.documentBlocks.sortIndex)); + return { + fieldId, + revision: field.revision, + contentHash: field.contentHash, + blocks: blocks.map((block) => ({ + id: block.id, + parentId: block.parentId, + kind: block.kind, + position: block.position, + addressable: block.addressable, + contentHash: block.contentHash, + markdown: block.markdown, + state: block.state === "deleted" ? "deleted" : "live", + deletedAtRevision: block.deletedAtRevision, + recoveredAtRevision: block.recoveredAtRevision, + })), + }; +} + +export async function readBlocksFieldIdentity(args: { + db?: ContentDb; + documentId: string; + propertyId: string; + markdown: string; +}): Promise { + const fieldId = blocksFieldId(args.documentId, args.propertyId); + const stored = await loadStoredIdentity(args.db ?? getDb(), fieldId); + return stored + ? exposeBlocksFieldIdentity(stored, args.markdown) + : legacyBlocksFieldIdentity(args); +} + +export async function readBlocksFieldIdentities(args: { + db?: ContentDb; + fields: Array<{ + documentId: string; + propertyId: string; + markdown: string; + }>; +}): Promise> { + const db = args.db ?? getDb(); + const inputs = new Map( + args.fields.map((field) => [ + blocksFieldId(field.documentId, field.propertyId), + field, + ]), + ); + const storedFields: Array = + []; + for (const ids of groups([...inputs.keys()], 200)) { + if (ids.length === 0) continue; + storedFields.push( + ...(await db + .select() + .from(schema.documentBlockFields) + .where(inArray(schema.documentBlockFields.id, ids))), + ); + } + const storedById = new Map(storedFields.map((field) => [field.id, field])); + const storedBlocks: Array = []; + for (const ids of groups( + storedFields.map((field) => field.id), + 200, + )) { + if (ids.length === 0) continue; + storedBlocks.push( + ...(await db + .select() + .from(schema.documentBlocks) + .where(inArray(schema.documentBlocks.fieldId, ids)) + .orderBy(asc(schema.documentBlocks.sortIndex))), + ); + } + const blocksByField = new Map< + string, + Array + >(); + for (const block of storedBlocks) { + const values = blocksByField.get(block.fieldId) ?? []; + values.push(block); + blocksByField.set(block.fieldId, values); + } + + const result = new Map(); + for (const [fieldId, input] of inputs) { + const field = storedById.get(fieldId); + if (!field) { + result.set(fieldId, legacyBlocksFieldIdentity(input)); + continue; + } + result.set( + fieldId, + exposeBlocksFieldIdentity( + { + fieldId, + revision: field.revision, + contentHash: field.contentHash, + blocks: (blocksByField.get(fieldId) ?? []).map((block) => ({ + id: block.id, + parentId: block.parentId, + kind: block.kind, + position: block.position, + addressable: block.addressable, + contentHash: block.contentHash, + markdown: block.markdown, + state: block.state === "deleted" ? "deleted" : "live", + deletedAtRevision: block.deletedAtRevision, + recoveredAtRevision: block.recoveredAtRevision, + })), + }, + input.markdown, + ), + ); + } + return result; +} + +export async function persistBlocksFieldIdentity(args: { + db: ContentDb; + ownerEmail: string; + documentId: string; + propertyId: string; + previousMarkdown: string; + markdown: string; + expectedRevision?: number; + now: string; +}): Promise { + const fieldId = blocksFieldId(args.documentId, args.propertyId); + const stored = await loadStoredIdentity(args.db, fieldId); + const actualRevision = stored?.revision ?? 0; + if ( + args.expectedRevision !== undefined && + args.expectedRevision !== actualRevision + ) { + throw new Error( + `Blocks field revision conflict: expected ${args.expectedRevision}, current ${actualRevision}`, + ); + } + + let previous = + stored ?? + materializeLegacyBlocksFieldIdentity({ + documentId: args.documentId, + propertyId: args.propertyId, + markdown: args.previousMarkdown, + }); + + // A legacy whole-field writer may have changed Markdown without updating the + // sidecar. Reconcile exact unique blocks first and report the resulting extra + // revision instead of silently pretending continuity was complete. + if (previous.contentHash !== blocksContentHash(args.previousMarkdown)) { + previous = reconcileBlocksFieldIdentity({ + documentId: args.documentId, + propertyId: args.propertyId, + previous, + markdown: args.previousMarkdown, + createId: () => `block_${nanoid(16)}`, + }); + } + + let next = reconcileBlocksFieldIdentity({ + documentId: args.documentId, + propertyId: args.propertyId, + previous, + markdown: args.markdown, + createId: () => `block_${nanoid(16)}`, + }); + + if (next.blocks.length > 0) { + const existingOwners = await args.db + .select({ + id: schema.documentBlocks.id, + fieldId: schema.documentBlocks.fieldId, + }) + .from(schema.documentBlocks) + .where( + inArray( + schema.documentBlocks.id, + next.blocks.map((block) => block.id), + ), + ); + const remappedIds = new Map(); + const reserved = new Set(next.blocks.map((block) => block.id)); + for (const existing of existingOwners) { + if (existing.fieldId === fieldId) continue; + let replacement = `block_${nanoid(16)}`; + while (reserved.has(replacement)) replacement = `block_${nanoid(16)}`; + reserved.add(replacement); + remappedIds.set(existing.id, replacement); + } + if (remappedIds.size > 0) { + next = { + ...next, + blocks: next.blocks.map((block) => ({ + ...block, + id: remappedIds.get(block.id) ?? block.id, + parentId: block.parentId + ? (remappedIds.get(block.parentId) ?? block.parentId) + : null, + })), + }; + } + } + + if (stored) { + const applied = await args.db + .update(schema.documentBlockFields) + .set({ + revision: next.revision, + contentHash: next.contentHash, + updatedAt: args.now, + }) + .where( + and( + eq(schema.documentBlockFields.id, fieldId), + eq(schema.documentBlockFields.revision, actualRevision), + ), + ) + .returning({ id: schema.documentBlockFields.id }); + if (applied.length === 0) { + throw new Error( + `Blocks field revision conflict: expected ${actualRevision}, current revision changed`, + ); + } + } else { + const inserted = await args.db + .insert(schema.documentBlockFields) + .values({ + id: fieldId, + ownerEmail: args.ownerEmail, + documentId: args.documentId, + propertyId: args.propertyId, + revision: next.revision, + contentHash: next.contentHash, + createdAt: args.now, + updatedAt: args.now, + }) + .onConflictDoNothing() + .returning({ id: schema.documentBlockFields.id }); + if (inserted.length === 0) { + throw new Error( + "Blocks field revision conflict: concurrent first materialization", + ); + } + } + await args.db + .delete(schema.documentBlocks) + .where(eq(schema.documentBlocks.fieldId, fieldId)); + if (next.blocks.length > 0) { + await args.db.insert(schema.documentBlocks).values( + next.blocks.map((block, sortIndex) => ({ + id: block.id, + ownerEmail: args.ownerEmail, + fieldId, + parentId: block.parentId, + kind: block.kind, + position: block.position, + sortIndex, + addressable: block.addressable, + contentHash: block.contentHash, + markdown: block.markdown, + state: block.state, + deletedAtRevision: block.deletedAtRevision, + recoveredAtRevision: block.recoveredAtRevision, + createdAt: args.now, + updatedAt: args.now, + })), + ); + } + return next; +} + +export async function deleteBlocksFieldIdentity(args: { + db: ContentDb; + documentId?: string; + propertyId?: string; +}): Promise { + if (!args.documentId && !args.propertyId) return; + const clauses = []; + if (args.documentId) { + clauses.push(eq(schema.documentBlockFields.documentId, args.documentId)); + } + if (args.propertyId) { + clauses.push(eq(schema.documentBlockFields.propertyId, args.propertyId)); + } + const fields = await args.db + .select({ id: schema.documentBlockFields.id }) + .from(schema.documentBlockFields) + .where(clauses.length === 1 ? clauses[0] : and(...clauses)); + if (fields.length === 0) return; + await args.db.delete(schema.documentBlocks).where( + inArray( + schema.documentBlocks.fieldId, + fields.map((field) => field.id), + ), + ); + await args.db.delete(schema.documentBlockFields).where( + inArray( + schema.documentBlockFields.id, + fields.map((field) => field.id), + ), + ); +} diff --git a/templates/content/actions/_property-utils.ts b/templates/content/actions/_property-utils.ts index 6b45736dca..1b92514ebb 100644 --- a/templates/content/actions/_property-utils.ts +++ b/templates/content/actions/_property-utils.ts @@ -21,6 +21,7 @@ import type { ContentDatabaseOpenPagesIn, DocumentProperty, } from "../shared/api.js"; +import { blocksFieldId } from "../shared/blocks-field-identity.js"; import { DEFAULT_BLOCKS_FIELD_NAME, defaultPropertyOptions, @@ -42,6 +43,7 @@ import { type DocumentPropertyValue, } from "../shared/properties.js"; import { chunks } from "./_batch-utils.js"; +import { readBlocksFieldIdentities } from "./_blocks-field-identity.js"; import { propertyDefinitionsPositionScope, withPositionLock, @@ -540,10 +542,46 @@ export async function listPropertiesForDatabase( ? await blockFieldContentsForDocument(valueDocument.id) : new Map(); + const blocksFieldIdentityById = valueDocument + ? await readBlocksFieldIdentities({ + db, + fields: definitions.flatMap((definition) => { + const type = definition.type as DocumentPropertyType; + if (!isBlocksPropertyType(type)) return []; + const options = parsePropertyOptions(definition.optionsJson); + return [ + { + documentId: valueDocument.id, + propertyId: definition.id, + markdown: resolveBlocksFieldValue({ + options, + documentBody: valueDocument.content, + blockFieldContent: blockContentByPropertyId.get(definition.id), + }), + }, + ]; + }), + }) + : new Map(); + const properties = definitions.map((definition) => { const type = definition.type as DocumentPropertyType; const storedValue = valueByPropertyId.get(definition.id); const options = parsePropertyOptions(definition.optionsJson); + const value = + valueDocument && isComputedPropertyType(type) && type !== "formula" + ? computedPropertyValue(type, valueDocument, { + databaseRowNumber: rowNumberByDocumentId.get(valueDocument.id), + }) + : valueDocument && isBlocksPropertyType(type) + ? // Each Blocks field reads from exactly one place: the primary from + // the document body, additional fields from their own store. + resolveBlocksFieldValue({ + options, + documentBody: valueDocument.content, + blockFieldContent: blockContentByPropertyId.get(definition.id), + }) + : parsePropertyValue(storedValue?.valueJson); return { definition: { id: definition.id, @@ -560,21 +598,15 @@ export async function listPropertiesForDatabase( createdAt: definition.createdAt, updatedAt: definition.updatedAt, }, - value: - valueDocument && isComputedPropertyType(type) && type !== "formula" - ? computedPropertyValue(type, valueDocument, { - databaseRowNumber: rowNumberByDocumentId.get(valueDocument.id), - }) - : valueDocument && isBlocksPropertyType(type) - ? // Each Blocks field reads from exactly one place: the primary from - // the document body, additional fields from their own store. - resolveBlocksFieldValue({ - options, - documentBody: valueDocument.content, - blockFieldContent: blockContentByPropertyId.get(definition.id), - }) - : parsePropertyValue(storedValue?.valueJson), + value, editable: !definition.systemRole && !isComputedPropertyType(type), + ...(valueDocument && isBlocksPropertyType(type) + ? { + blocksField: blocksFieldIdentityById.get( + blocksFieldId(valueDocument.id, definition.id), + ), + } + : {}), }; }); @@ -724,32 +756,64 @@ export async function listPropertiesForDatabaseDocuments( } } + const blocksFieldIdentityById = await readBlocksFieldIdentities({ + db, + fields: valueDocuments.flatMap((document) => + definitions.flatMap((definition) => { + const type = definition.type as DocumentPropertyType; + if (!isBlocksPropertyType(type)) return []; + const options = parsePropertyOptions(definition.optionsJson); + return [ + { + documentId: document.id, + propertyId: definition.id, + markdown: resolveBlocksFieldValue({ + options, + documentBody: document.content, + blockFieldContent: blockContentByDocumentAndProperty.get( + propertyValueKey(document.id, definition.id), + ), + }), + }, + ]; + }), + ), + }); + for (const document of valueDocuments) { const properties = definitions.map((definition) => { const propertyDefinition = serializePropertyDefinition(definition); const storedValue = valueByDocumentAndProperty.get( propertyValueKey(document.id, definition.id), ); + const value = + isComputedPropertyType(propertyDefinition.type) && + propertyDefinition.type !== "formula" + ? computedPropertyValue(propertyDefinition.type, document, { + databaseRowNumber: rowNumberByDocumentId.get(document.id), + }) + : isBlocksPropertyType(propertyDefinition.type) + ? resolveBlocksFieldValue({ + options: propertyDefinition.options, + documentBody: document.content, + blockFieldContent: blockContentByDocumentAndProperty.get( + propertyValueKey(document.id, definition.id), + ), + }) + : parsePropertyValue(storedValue?.valueJson); return { definition: propertyDefinition, - value: - isComputedPropertyType(propertyDefinition.type) && - propertyDefinition.type !== "formula" - ? computedPropertyValue(propertyDefinition.type, document, { - databaseRowNumber: rowNumberByDocumentId.get(document.id), - }) - : isBlocksPropertyType(propertyDefinition.type) - ? resolveBlocksFieldValue({ - options: propertyDefinition.options, - documentBody: document.content, - blockFieldContent: blockContentByDocumentAndProperty.get( - propertyValueKey(document.id, definition.id), - ), - }) - : parsePropertyValue(storedValue?.valueJson), + value, editable: !definition.systemRole && !isComputedPropertyType(propertyDefinition.type), + ...(isBlocksPropertyType(propertyDefinition.type) + ? { + blocksField: blocksFieldIdentityById.get( + blocksFieldId(document.id, definition.id), + ), + } + : {}), }; }); diff --git a/templates/content/actions/blocks-seeding.db.test.ts b/templates/content/actions/blocks-seeding.db.test.ts index 3690bbf606..1e41014f64 100644 --- a/templates/content/actions/blocks-seeding.db.test.ts +++ b/templates/content/actions/blocks-seeding.db.test.ts @@ -29,6 +29,7 @@ type Schema = typeof import("../server/db/schema.js"); let getDb: () => any; let schema: Schema; let propertyUtils: typeof import("./_property-utils.js"); +let identityUtils: typeof import("./_blocks-field-identity.js"); let databaseUtils: typeof import("./_database-utils.js"); let createInlineContentDatabaseAction: typeof import("./create-inline-content-database.js").default; let updateDocumentAction: typeof import("./update-document.js").default; @@ -38,6 +39,7 @@ let getContentDatabaseAction: typeof import("./get-content-database.js").default let getDocumentAction: typeof import("./get-document.js").default; let configureDocumentPropertyAction: typeof import("./configure-document-property.js").default; let addDatabaseItemAction: typeof import("./add-database-item.js").default; +let removeDatabaseItemsAction: typeof import("./remove-database-items.js").default; const OWNER = "owner@example.com"; @@ -47,6 +49,7 @@ beforeAll(async () => { getDb = dbModule.getDb; schema = dbModule.schema; propertyUtils = await import("./_property-utils.js"); + identityUtils = await import("./_blocks-field-identity.js"); databaseUtils = await import("./_database-utils.js"); createInlineContentDatabaseAction = ( await import("./create-inline-content-database.js") @@ -61,6 +64,8 @@ beforeAll(async () => { await import("./configure-document-property.js") ).default; addDatabaseItemAction = (await import("./add-database-item.js")).default; + removeDatabaseItemsAction = (await import("./remove-database-items.js")) + .default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); }, 60000); // cold-import of the db module + migrations exceeds the default 10s hook timeout @@ -654,6 +659,274 @@ describe("writeBlockFieldContent — upsert race (finding 4)", () => { }); }); +describe("database Blocks field identity sidecar", () => { + it("preserves ordered IDs, independent revisions, and bounded recovery", async () => { + const { documentId } = await createDatabaseRow(); + const db = getDb(); + const primaryPropertyId = `primary_${documentId}`; + const additionalPropertyId = `additional_${documentId}`; + + async function save(args: { + propertyId: string; + previousMarkdown: string; + markdown: string; + expectedRevision: number; + }) { + const now = new Date().toISOString(); + return db.transaction(async (tx: any) => { + const state = await identityUtils.persistBlocksFieldIdentity({ + db: tx, + ownerEmail: OWNER, + documentId, + propertyId: args.propertyId, + previousMarkdown: args.previousMarkdown, + markdown: args.markdown, + expectedRevision: args.expectedRevision, + now, + }); + if (args.propertyId === primaryPropertyId) { + await tx + .update(schema.documents) + .set({ content: args.markdown, updatedAt: now }) + .where(eq(schema.documents.id, documentId)); + } + return state; + }); + } + + const edited = await save({ + propertyId: primaryPropertyId, + previousMarkdown: "body text", + markdown: "Alpha\nBeta", + expectedRevision: 0, + }); + const [alphaId, betaId] = edited.blocks + .filter((block) => block.state === "live") + .map((block) => block.id); + expect(edited.revision).toBe(1); + + const reordered = await save({ + propertyId: primaryPropertyId, + previousMarkdown: "Alpha\nBeta", + markdown: "Beta\nAlpha edited", + expectedRevision: 1, + }); + expect( + reordered.blocks + .filter((block) => block.state === "live") + .map((block) => block.id), + ).toEqual([betaId, alphaId]); + + const deleted = await save({ + propertyId: primaryPropertyId, + previousMarkdown: "Beta\nAlpha edited", + markdown: "Alpha edited", + expectedRevision: 2, + }); + expect(deleted.blocks.find((block) => block.id === betaId)).toEqual( + expect.objectContaining({ state: "deleted", deletedAtRevision: 3 }), + ); + + const recovered = await save({ + propertyId: primaryPropertyId, + previousMarkdown: "Alpha edited", + markdown: "Alpha edited\nBeta", + expectedRevision: 3, + }); + expect( + recovered.blocks + .filter((block) => block.state === "live") + .map((block) => block.id), + ).toContain(betaId); + + const additional = await save({ + propertyId: additionalPropertyId, + previousMarkdown: "", + markdown: "Alpha edited\nBeta", + expectedRevision: 0, + }); + expect(additional.fieldId).not.toBe(recovered.fieldId); + expect(additional.revision).toBe(1); + expect(additional.blocks.map((block) => block.id)).not.toEqual( + recovered.blocks.map((block) => block.id), + ); + + const reloaded = await identityUtils.readBlocksFieldIdentity({ + documentId, + propertyId: primaryPropertyId, + markdown: "Alpha edited\nBeta", + }); + expect(reloaded.revision).toBe(4); + expect(reloaded.identityStatus).toBe("materialized"); + expect(reloaded.blocks.map((block) => block.id)).toEqual( + recovered.blocks + .filter((block) => block.state === "live") + .map((block) => block.id), + ); + }); + + it("rejects a stale field revision without changing canonical Markdown", async () => { + const { documentId } = await createDatabaseRow(); + const db = getDb(); + const propertyId = `conflict_${documentId}`; + const now = new Date().toISOString(); + await identityUtils.persistBlocksFieldIdentity({ + db, + ownerEmail: OWNER, + documentId, + propertyId, + previousMarkdown: "body text", + markdown: "Current", + expectedRevision: 0, + now, + }); + await db + .update(schema.documents) + .set({ content: "Current", updatedAt: now }) + .where(eq(schema.documents.id, documentId)); + + await expect( + db.transaction(async (tx: any) => { + await tx + .update(schema.documents) + .set({ content: "Stale overwrite" }) + .where(eq(schema.documents.id, documentId)); + await identityUtils.persistBlocksFieldIdentity({ + db: tx, + ownerEmail: OWNER, + documentId, + propertyId, + previousMarkdown: "Current", + markdown: "Stale overwrite", + expectedRevision: 0, + now: new Date().toISOString(), + }); + }), + ).rejects.toThrow("Blocks field revision conflict"); + + const [document] = await db + .select({ content: schema.documents.content }) + .from(schema.documents) + .where(eq(schema.documents.id, documentId)); + expect(document.content).toBe("Current"); + }); + + it("allows only one concurrent first materialization", async () => { + const { documentId } = await createDatabaseRow(); + const db = getDb(); + const propertyId = `first_${documentId}`; + const attempts = await Promise.allSettled( + ["First", "Second"].map((markdown) => + db.transaction((tx: any) => + identityUtils.persistBlocksFieldIdentity({ + db: tx, + ownerEmail: OWNER, + documentId, + propertyId, + previousMarkdown: "body text", + markdown, + expectedRevision: 0, + now: new Date().toISOString(), + }), + ), + ), + ); + + expect( + attempts.filter((attempt) => attempt.status === "fulfilled"), + ).toHaveLength(1); + expect( + attempts.filter((attempt) => attempt.status === "rejected"), + ).toHaveLength(1); + const [field] = await db + .select() + .from(schema.documentBlockFields) + .where(eq(schema.documentBlockFields.propertyId, propertyId)); + expect(field.revision).toBe(1); + }); + + it("revisions every primary membership and cleans up only the removed database", async () => { + const first = await createDatabaseRow(); + const second = await createDatabaseRow(); + const db = getDb(); + const now = new Date().toISOString(); + const firstPropertyId = await propertyUtils.seedDefaultBlocksField({ + databaseId: first.databaseId, + ownerEmail: OWNER, + orgId: null, + now, + }); + const secondPropertyId = await propertyUtils.seedDefaultBlocksField({ + databaseId: second.databaseId, + ownerEmail: OWNER, + orgId: null, + now, + }); + const rowDocumentId = `multi_membership_${counter}`; + const firstItemId = `item_first_${counter}`; + await db.insert(schema.documents).values({ + id: rowDocumentId, + ownerEmail: OWNER, + title: "Shared row", + content: "Before", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseItems).values([ + { + id: firstItemId, + ownerEmail: OWNER, + databaseId: first.databaseId, + documentId: rowDocumentId, + position: 0, + createdAt: now, + updatedAt: now, + }, + { + id: `item_second_${counter}`, + ownerEmail: OWNER, + databaseId: second.databaseId, + documentId: rowDocumentId, + position: 0, + createdAt: now, + updatedAt: now, + }, + ]); + + await runWithRequestContext({ userEmail: OWNER }, () => + updateDocumentAction.run({ id: rowDocumentId, content: "After" }), + ); + const beforeRemoval = await db + .select() + .from(schema.documentBlockFields) + .where(eq(schema.documentBlockFields.documentId, rowDocumentId)); + expect( + new Map( + beforeRemoval.map((field: any) => [field.propertyId, field.revision]), + ), + ).toEqual( + new Map([ + [firstPropertyId, 1], + [secondPropertyId, 1], + ]), + ); + + await runWithRequestContext({ userEmail: OWNER }, () => + removeDatabaseItemsAction.run({ + databaseId: first.databaseId, + itemIds: [firstItemId], + }), + ); + const afterRemoval = await db + .select() + .from(schema.documentBlockFields) + .where(eq(schema.documentBlockFields.documentId, rowDocumentId)); + expect(afterRemoval).toEqual([ + expect.objectContaining({ propertyId: secondPropertyId, revision: 1 }), + ]); + }); +}); + describe("cascade cleanup of block-field content on delete (finding 7)", () => { it("deletes block-field rows by document id when a row document is deleted", async () => { const { databaseId } = await createDatabaseRow(); diff --git a/templates/content/actions/configure-document-property.ts b/templates/content/actions/configure-document-property.ts index 3db35a7c95..fa42f132c0 100644 --- a/templates/content/actions/configure-document-property.ts +++ b/templates/content/actions/configure-document-property.ts @@ -16,6 +16,7 @@ import { normalizePropertyVisibility, type DocumentPropertyType, } from "../shared/properties.js"; +import { deleteBlocksFieldIdentity } from "./_blocks-field-identity.js"; import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; import { lockDatabaseMemberships } from "./_database-membership-lock.js"; import { @@ -233,6 +234,10 @@ export default defineAction({ ) && !isBlocksPropertyType(type) ) { + await deleteBlocksFieldIdentity({ + db: tx as unknown as ReturnType, + propertyId: args.id!, + }); await tx .delete(schema.documentBlockFieldContents) .where( diff --git a/templates/content/actions/delete-document-property.ts b/templates/content/actions/delete-document-property.ts index 9330e9c225..fe41721b69 100644 --- a/templates/content/actions/delete-document-property.ts +++ b/templates/content/actions/delete-document-property.ts @@ -11,6 +11,7 @@ import { parsePropertyOptions, type DocumentPropertyType, } from "../shared/properties.js"; +import { deleteBlocksFieldIdentity } from "./_blocks-field-identity.js"; import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; import { lockDatabaseMemberships } from "./_database-membership-lock.js"; import { @@ -117,6 +118,10 @@ export default defineAction({ .where(eq(schema.documentPropertyDefinitions.id, propertyId)); if (isBlocks) { + await deleteBlocksFieldIdentity({ + db: tx as unknown as ReturnType, + propertyId, + }); await tx .delete(schema.documentBlockFieldContents) .where(eq(schema.documentBlockFieldContents.propertyId, propertyId)); diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index 855c0eba33..f61054c2d0 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { chunks } from "./_batch-utils.js"; +import { deleteBlocksFieldIdentity } from "./_blocks-field-identity.js"; import { lockContentDatabaseMutation, touchContentDatabase, @@ -730,6 +731,9 @@ async function deleteCollectedDocuments( }); await deleteWhereIn(documentIds, async (documentIdBatch) => { + for (const documentId of documentIdBatch) { + await deleteBlocksFieldIdentity({ db, documentId }); + } await db .delete(schema.contentDatabaseBodyHydrationQueue) .where( diff --git a/templates/content/actions/export-document.ts b/templates/content/actions/export-document.ts index e4c9be59af..c4bd317ba4 100644 --- a/templates/content/actions/export-document.ts +++ b/templates/content/actions/export-document.ts @@ -3,8 +3,14 @@ import { buildDeepLink } from "@agent-native/core/server"; import { resolveAccess } from "@agent-native/core/sharing"; import { z } from "zod"; +import { blocksContentHash } from "../shared/blocks-field-identity.js"; import { buildDocumentExport } from "../shared/document-export.js"; +import { + isBlocksPropertyType, + isPrimaryBlocksField, +} from "../shared/properties.js"; import "../server/db/index.js"; +import { listPropertiesForDocument } from "./_property-utils.js"; export default defineAction({ description: @@ -33,12 +39,41 @@ export default defineAction({ if (!access) throw new Error(`Document "${id}" not found`); const doc = access.resource; + const properties = await listPropertiesForDocument(doc); + const blocksFields = properties + .filter((property) => isBlocksPropertyType(property.definition.type)) + .map((property) => { + if (!property.blocksField) { + throw new Error( + `Blocks field "${property.definition.id}" has no identity state`, + ); + } + const markdown = + content !== undefined && + isPrimaryBlocksField(property.definition.options) + ? content + : typeof property.value === "string" + ? property.value + : ""; + const identity = + blocksContentHash(markdown) === property.blocksField.contentHash + ? property.blocksField + : { ...property.blocksField, identityStatus: "stale" as const }; + return { + propertyId: property.definition.id, + name: property.definition.name, + position: property.definition.position, + markdown, + identity, + }; + }); const payload = buildDocumentExport({ id: doc.id, title: title ?? doc.title, content: content ?? doc.content, updatedAt: doc.updatedAt, format, + blocksFields, }); return { diff --git a/templates/content/actions/migrate-content-database-rows.postgres.integration.test.ts b/templates/content/actions/migrate-content-database-rows.postgres.integration.test.ts index ed0fd864f0..7de2f520c6 100644 --- a/templates/content/actions/migrate-content-database-rows.postgres.integration.test.ts +++ b/templates/content/actions/migrate-content-database-rows.postgres.integration.test.ts @@ -30,6 +30,7 @@ let deleteContentDatabase: typeof import("./delete-content-database.js").default let restoreDocument: typeof import("./restore-document.js").default; let restoreContentDatabase: typeof import("./restore-content-database.js").default; let permanentlyDeleteDocument: typeof import("./permanently-delete-document.js").default; +let blocksFieldIdentity: typeof import("./_blocks-field-identity.js"); beforeAll(async () => { if (!POSTGRES_URL) return; @@ -58,6 +59,7 @@ beforeAll(async () => { .default; permanentlyDeleteDocument = (await import("./permanently-delete-document.js")) .default; + blocksFieldIdentity = await import("./_blocks-field-identity.js"); await (await import("../server/plugins/db.js")).default(undefined as any); }, 60_000); @@ -196,6 +198,10 @@ async function fixture() { async function cleanupFixture(seed: Awaited>) { await getDb().transaction(async (tx: any) => { + await blocksFieldIdentity.deleteBlocksFieldIdentity({ + db: tx, + documentId: seed.documentId, + }); await tx .delete(schema.contentDatabaseMigrationReceipts) .where( @@ -246,6 +252,57 @@ async function waitForPostgresLockWait(minimum: number) { const postgresSuite = POSTGRES_URL ? describe : describe.skip; postgresSuite("migrate-content-database-rows PostgreSQL locking", () => { + it("persists the same field identity and revision contract on PostgreSQL", async () => { + const seed = await fixture(); + const propertyId = `blocks_${seed.documentId}`; + const now = new Date().toISOString(); + try { + const first = await getDb().transaction((tx: any) => + blocksFieldIdentity.persistBlocksFieldIdentity({ + db: tx, + ownerEmail: OWNER, + documentId: seed.documentId, + propertyId, + previousMarkdown: "# Before", + markdown: "Alpha\nBeta", + expectedRevision: 0, + now, + }), + ); + const second = await getDb().transaction((tx: any) => + blocksFieldIdentity.persistBlocksFieldIdentity({ + db: tx, + ownerEmail: OWNER, + documentId: seed.documentId, + propertyId, + previousMarkdown: "Alpha\nBeta", + markdown: "Beta\nAlpha edited", + expectedRevision: 1, + now: new Date().toISOString(), + }), + ); + + expect(second.revision).toBe(2); + expect( + second.blocks + .filter((block) => block.state === "live") + .map((block) => block.id), + ).toEqual([ + first.blocks.find((block) => block.markdown === "Beta")?.id, + first.blocks.find((block) => block.markdown === "Alpha")?.id, + ]); + const reloaded = await blocksFieldIdentity.readBlocksFieldIdentity({ + documentId: seed.documentId, + propertyId, + markdown: "Beta\nAlpha edited", + }); + expect(reloaded.identityStatus).toBe("materialized"); + expect(reloaded.revision).toBe(2); + } finally { + await cleanupFixture(seed); + } + }); + it("rejects stale plans before requesting an editor flush", async () => { const seed = await fixture(); try { diff --git a/templates/content/actions/remove-database-items.ts b/templates/content/actions/remove-database-items.ts index 14fb76e312..8fae193cd1 100644 --- a/templates/content/actions/remove-database-items.ts +++ b/templates/content/actions/remove-database-items.ts @@ -4,6 +4,7 @@ import { assertAccess } from "@agent-native/core/sharing"; import { and, eq, inArray } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; +import { deleteBlocksFieldIdentity } from "./_blocks-field-identity.js"; import { lockContentDatabaseMutation, touchContentDatabase, @@ -132,6 +133,15 @@ export default defineAction({ ) ).map((property) => property.id); if (removedDocumentIds.length > 0 && propertyIds.length > 0) { + for (const removedDocumentId of removedDocumentIds) { + for (const propertyId of propertyIds) { + await deleteBlocksFieldIdentity({ + db: tx as unknown as ReturnType, + documentId: removedDocumentId, + propertyId, + }); + } + } await tx .delete(schema.documentPropertyValues) .where( diff --git a/templates/content/actions/set-document-property.ts b/templates/content/actions/set-document-property.ts index cafd59097f..4430a72b17 100644 --- a/templates/content/actions/set-document-property.ts +++ b/templates/content/actions/set-document-property.ts @@ -12,6 +12,7 @@ import { parsePropertyOptions, type DocumentPropertyType, } from "../shared/properties.js"; +import { persistBlocksFieldIdentity } from "./_blocks-field-identity.js"; import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; import { resolveContentDocumentAccess } from "./_content-document-access.js"; import { lockDatabaseMemberships } from "./_database-membership-lock.js"; @@ -34,8 +35,15 @@ export default defineAction({ ), propertyId: z.string().describe("Property definition ID"), value: z.unknown().describe("Value for the property type"), + expectedBlocksFieldRevision: z.number().int().nonnegative().optional(), }), - run: async ({ documentId, databaseId, propertyId, value }) => { + run: async ({ + documentId, + databaseId, + propertyId, + value, + expectedBlocksFieldRevision, + }) => { const db = getDb(); const [definition] = await db .select() @@ -116,12 +124,31 @@ export default defineAction({ target = blocksStorageTarget( parsePropertyOptions(lockedDefinition.optionsJson), ); + let previousContent = ""; if (target === "document_body") { + const [currentDocument] = await tx + .select({ content: schema.documents.content }) + .from(schema.documents) + .where(eq(schema.documents.id, documentId)); + if (!currentDocument) { + throw new Error(`Document "${documentId}" not found`); + } + previousContent = currentDocument.content; await tx .update(schema.documents) .set({ content, updatedAt: now }) .where(eq(schema.documents.id, documentId)); } else { + const [currentField] = await tx + .select({ content: schema.documentBlockFieldContents.content }) + .from(schema.documentBlockFieldContents) + .where( + and( + eq(schema.documentBlockFieldContents.documentId, documentId), + eq(schema.documentBlockFieldContents.propertyId, propertyId), + ), + ); + previousContent = currentField?.content ?? ""; await tx .insert(schema.documentBlockFieldContents) .values({ @@ -141,6 +168,16 @@ export default defineAction({ set: { content, updatedAt: now }, }); } + await persistBlocksFieldIdentity({ + db: tx as unknown as ReturnType, + ownerEmail: database.ownerEmail, + documentId, + propertyId, + previousMarkdown: previousContent, + markdown: content, + expectedRevision: expectedBlocksFieldRevision, + now, + }); }); return { documentId, diff --git a/templates/content/actions/update-document.ts b/templates/content/actions/update-document.ts index b45ca80614..fc21d3fcff 100644 --- a/templates/content/actions/update-document.ts +++ b/templates/content/actions/update-document.ts @@ -19,6 +19,7 @@ import { parseDocumentHideFromSearch, } from "../server/lib/documents.js"; import type { DocumentUpdateResponse } from "../shared/api.js"; +import { persistBlocksFieldIdentity } from "./_blocks-field-identity.js"; import { BUILDER_CMS_BODY_CONTENT_KEY } from "./_builder-cms-source-adapter.js"; import { reconcileInlineDatabasesForDocument } from "./_content-database-lifecycle.js"; import { resolveContentDocumentAccess } from "./_content-document-access.js"; @@ -523,6 +524,38 @@ export default defineAction({ .where(eq(schema.documents.id, id)); } + if (contentChanged && content !== undefined) { + const primaryBlocksFields = await tx + .select({ + propertyId: schema.contentDatabases.primaryBlocksPropertyId, + }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.contentDatabases, + eq( + schema.contentDatabases.id, + schema.contentDatabaseItems.databaseId, + ), + ) + .where(eq(schema.contentDatabaseItems.documentId, id)); + const primaryPropertyIds = new Set( + primaryBlocksFields.flatMap((field) => + field.propertyId ? [field.propertyId] : [], + ), + ); + for (const propertyId of primaryPropertyIds) { + await persistBlocksFieldIdentity({ + db: tx as unknown as ReturnType, + ownerEmail, + documentId: id, + propertyId, + previousMarkdown: existing.content, + markdown: content, + now: updates.updatedAt as string, + }); + } + } + if (titleChanged || contentChanged) { const [latestVersion] = await tx .select({ createdAt: schema.documentVersions.createdAt }) diff --git a/templates/content/app/components/editor/DocumentBlockFields.tsx b/templates/content/app/components/editor/DocumentBlockFields.tsx index c080975d66..a6c8a38c07 100644 --- a/templates/content/app/components/editor/DocumentBlockFields.tsx +++ b/templates/content/app/components/editor/DocumentBlockFields.tsx @@ -657,15 +657,18 @@ export function useBlockFieldEditor({ documentId, propertyId, initialContent, + initialRevision, save, }: { documentId: string; propertyId: string; initialContent: string; + initialRevision: number; save: (request: { documentId: string; propertyId: string; value: string; + expectedBlocksFieldRevision: number; }) => Promise; }): { content: string; onChange: (markdown: string) => void } { const key = `${documentId}:${propertyId}`; @@ -674,7 +677,24 @@ export function useBlockFieldEditor({ // save TARGET (documentId:propertyId) is fixed by the key; only the function // identity changes per mount, never the field it writes to. const implRef = blockFieldSaveImplRef(key); - implRef.current = (value: string) => save({ documentId, propertyId, value }); + const revisionRef = useRef(initialRevision); + if (initialRevision > revisionRef.current) { + revisionRef.current = initialRevision; + } + implRef.current = async (value: string) => { + const response = await save({ + documentId, + propertyId, + value, + expectedBlocksFieldRevision: revisionRef.current, + }); + const nextRevision = ( + response as DocumentPropertiesResponse + )?.properties?.find((candidate) => candidate.definition.id === propertyId) + ?.blocksField?.revision; + if (typeof nextRevision === "number") revisionRef.current = nextRevision; + return response; + }; // Build (but do not yet ref-count) the controller factory for this key. The // formal acquire/release happens in the effect below; we only need the factory @@ -823,6 +843,7 @@ function AdditionalBlockEditor({ documentId, propertyId, initialContent, + initialRevision: property.blocksField?.revision ?? 0, save: setProperty.mutateAsync, }); diff --git a/templates/content/app/components/editor/useBlockFieldEditor.test.tsx b/templates/content/app/components/editor/useBlockFieldEditor.test.tsx index 280a9de77c..7e0a14a6ee 100644 --- a/templates/content/app/components/editor/useBlockFieldEditor.test.tsx +++ b/templates/content/app/components/editor/useBlockFieldEditor.test.tsx @@ -11,7 +11,12 @@ import { useBlockFieldEditor } from "./DocumentBlockFields"; // A save record we can assert against: which (documentId, propertyId) each // write targeted, and with what value. Resolves immediately so single-flight + // trailing logic settles within an act(). -type SaveCall = { documentId: string; propertyId: string; value: string }; +type SaveCall = { + documentId: string; + propertyId: string; + value: string; + expectedBlocksFieldRevision: number; +}; describe("useBlockFieldEditor (identity-safe save wiring)", () => { let container: HTMLDivElement | null = null; @@ -50,6 +55,7 @@ describe("useBlockFieldEditor (identity-safe save wiring)", () => { documentId, propertyId, initialContent, + initialRevision: 0, save, }); onReady(onChange); @@ -116,6 +122,7 @@ describe("useBlockFieldEditor (identity-safe save wiring)", () => { documentId: "doc-new", propertyId: "summary", value: "new doc text", + expectedBlocksFieldRevision: 0, }); // The new field's write never leaked to the old field. expect( @@ -182,6 +189,7 @@ describe("useBlockFieldEditor (identity-safe save wiring)", () => { documentId: "doc-old", propertyId: "outline", value: "unsaved old-field edit", + expectedBlocksFieldRevision: 0, }); // It did NOT get misrouted to the new field. expect(calls.some((c) => c.documentId === "doc-new")).toBe(false); @@ -452,6 +460,7 @@ describe("useBlockFieldEditor (identity-safe save wiring)", () => { documentId: "doc", propertyId: "field", value: "saved value", + expectedBlocksFieldRevision: 0, }); // REMOUNT while the server query has NOT yet refetched — initialContent is @@ -631,7 +640,12 @@ describe("useBlockFieldEditor (identity-safe save wiring)", () => { expect(seenContent).toBe("agent edit"); // Only the original local save happened; adopting never saves. expect(calls).toEqual([ - { documentId: "doc", propertyId: "field", value: "mine" }, + { + documentId: "doc", + propertyId: "field", + value: "mine", + expectedBlocksFieldRevision: 0, + }, ]); }); diff --git a/templates/content/changelog/2026-08-10-database-blocks-fields-now-keep-logical-block-identity-throu.md b/templates/content/changelog/2026-08-10-database-blocks-fields-now-keep-logical-block-identity-throu.md new file mode 100644 index 0000000000..4c7fba5e2e --- /dev/null +++ b/templates/content/changelog/2026-08-10-database-blocks-fields-now-keep-logical-block-identity-throu.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-08-10 +--- + +Database Blocks fields now keep logical block identity through editing, reordering, deletion recovery, and reloads diff --git a/templates/content/docs/product/capabilities/content.object.block.md b/templates/content/docs/product/capabilities/content.object.block.md index e76ab035e8..79a20f9dff 100644 --- a/templates/content/docs/product/capabilities/content.object.block.md +++ b/templates/content/docs/product/capabilities/content.object.block.md @@ -6,7 +6,7 @@ name: "Blocks" user_promise: "A Block is a stable addressable unit of rich content inside its owning field." primary_user_job: "Edit and point to a meaningful part of content without fragile position-only anchors." kind: "primitive" -state: "approved_shape" +state: "in_progress" publicness: "public" availability: "universal" dependencies: [] @@ -21,9 +21,10 @@ proof_requirements: "Comment anchors retain historical target context", "Shared Action/UI editing, conflict, undo, and reload behavior", ] -evidence: [] +evidence: + ["shared/blocks-field-identity.ts", "actions/blocks-seeding.db.test.ts"] superseded_by: null -last_reviewed: "2026-07-29" +last_reviewed: "2026-08-10" --- # Blocks @@ -62,7 +63,7 @@ Given a Block reference in another Page, when an authorized reader opens it, the ## Current evidence -The current editor stores rich document content and supports anchored comments, but the repository does not yet demonstrate stable universal Block IDs, reference serialization, or recovery across all typed Block operations. This remains `approved_shape`. +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. ## 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 24ae48993f..34bf479261 100644 --- a/templates/content/docs/product/capabilities/content.object.blocks-field.md +++ b/templates/content/docs/product/capabilities/content.object.blocks-field.md @@ -6,7 +6,7 @@ name: "Blocks fields" user_promise: "Every editable rich-content body uses one Blocks-field grammar and keeps its own stable revision boundary." primary_user_job: "Write rich content in Pages and collaboration surfaces without each body inventing incompatible editing and history rules." kind: "primitive" -state: "approved_shape" +state: "in_progress" publicness: "public" availability: "universal" dependencies: ["content.object.block"] @@ -24,9 +24,14 @@ proof_requirements: "Owner-scoped access and typed rendering including unavailable content", "Shared Action/UI behavior for concurrency, history, and portable output", ] -evidence: [] +evidence: + [ + "server/db/schema.ts", + "actions/_blocks-field-identity.ts", + "actions/blocks-seeding.db.test.ts", + ] superseded_by: null -last_reviewed: "2026-07-29" +last_reviewed: "2026-08-10" --- # Blocks fields @@ -65,7 +70,7 @@ Given a Page with two Blocks fields, when an authorized editor restores one fiel ## Current evidence -The document editor proves rich Page-body editing and comments provide collaboration substrate. The repository does not yet prove one generalized Blocks-field grammar or independent field history across all owners; this remains `approved_shape`. +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. ## Proof plan diff --git a/templates/content/docs/product/encyclopedia.md b/templates/content/docs/product/encyclopedia.md index dd4746c305..6fe356d9bd 100644 --- a/templates/content/docs/product/encyclopedia.md +++ b/templates/content/docs/product/encyclopedia.md @@ -16,8 +16,8 @@ This index summarizes the atomic product contracts beneath the public roadmap. E | Verified | 3 | | Failing | 1 | | Stale | 0 | -| In Progress | 16 | -| Approved Shape | 91 | +| In Progress | 18 | +| Approved Shape | 89 | | Exploring | 8 | | Deferred | 0 | | Superseded | 5 | @@ -358,8 +358,8 @@ graph LR | Capability | State | User promise | | -------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------- | -| [Blocks](capabilities/content.object.block.md) | Approved Shape | A Block is a stable addressable unit of rich content inside its owning field. | -| [Blocks fields](capabilities/content.object.blocks-field.md) | Approved Shape | Every editable rich-content body uses one Blocks-field grammar and keeps its own stable revision boundary. | +| [Blocks](capabilities/content.object.block.md) | In Progress | A Block is a stable addressable unit of rich content inside its owning field. | +| [Blocks fields](capabilities/content.object.blocks-field.md) | In Progress | Every editable rich-content body uses one Blocks-field grammar and keeps its own stable revision boundary. | | [Databases](capabilities/content.object.database.md) | Verified | Database as a Page-backed typed collection | | [Multiple Database memberships](capabilities/content.object.multi-membership.md) | In Progress | One Page can belong to several Databases without copies or a hidden primary home. | | [Pages](capabilities/content.object.page.md) | Verified | A durable Page keeps its identity, body, properties, access, discussion, and portable representation wherever it appears. | diff --git a/templates/content/server/db/schema.ts b/templates/content/server/db/schema.ts index ba4ab76113..957f793110 100644 --- a/templates/content/server/db/schema.ts +++ b/templates/content/server/db/schema.ts @@ -523,4 +523,65 @@ export const documentBlockFieldContents = table( }, ); +// Stable identity and revision boundary for one database Blocks property. The +// Markdown body remains in documents.content or document_block_field_contents; +// this row binds the ordered identity sidecar to those exact bytes. +export const documentBlockFields = table( + "document_block_fields", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + documentId: text("document_id").notNull(), + propertyId: text("property_id").notNull(), + revision: integer("revision").notNull().default(0), + contentHash: text("content_hash").notNull(), + createdAt: text("created_at").notNull().default(now()), + updatedAt: text("updated_at").notNull().default(now()), + }, + (field) => [ + uniqueIndex("document_block_fields_document_property_unique").on( + field.documentId, + field.propertyId, + ), + index("document_block_fields_owner_document_idx").on( + field.ownerEmail, + field.documentId, + ), + ], +); + +// Ordered block identity index plus bounded tombstones. This is deliberately +// not an actor-aware history log: it records only current nodes and the minimum +// deleted fragment needed for editor undo to recover the same logical ID. +export const documentBlocks = table( + "document_blocks", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + fieldId: text("field_id").notNull(), + parentId: text("parent_id"), + kind: text("kind").notNull(), + position: integer("position").notNull(), + sortIndex: integer("sort_index").notNull(), + addressable: integer("addressable", { mode: "boolean" }) + .notNull() + .default(true), + contentHash: text("content_hash").notNull(), + markdown: text("markdown").notNull().default(""), + state: text("state").notNull().default("live"), + deletedAtRevision: integer("deleted_at_revision"), + recoveredAtRevision: integer("recovered_at_revision"), + createdAt: text("created_at").notNull().default(now()), + updatedAt: text("updated_at").notNull().default(now()), + }, + (block) => [ + index("document_blocks_field_state_sort_idx").on( + block.fieldId, + block.state, + block.sortIndex, + ), + index("document_blocks_parent_idx").on(block.parentId), + ], +); + export const documentShares = createSharesTable("document_shares"); diff --git a/templates/content/server/plugins/db.ts b/templates/content/server/plugins/db.ts index 1e1ba634d4..bbb6b3113f 100644 --- a/templates/content/server/plugins/db.ts +++ b/templates/content/server/plugins/db.ts @@ -956,6 +956,49 @@ export const runContentMigrations = runMigrations( CREATE INDEX IF NOT EXISTS content_database_migration_receipts_owner_database_idx ON content_database_migration_receipts (owner_email, database_id)`, }, + { + version: 81, + name: "content-block-field-identities", + sql: `CREATE TABLE IF NOT EXISTS document_block_fields ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + document_id TEXT NOT NULL, + property_id TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 0, + content_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE UNIQUE INDEX IF NOT EXISTS document_block_fields_document_property_unique + ON document_block_fields (document_id, property_id); + CREATE INDEX IF NOT EXISTS document_block_fields_owner_document_idx + ON document_block_fields (owner_email, document_id)`, + }, + { + version: 82, + name: "content-block-identities-and-tombstones", + sql: `CREATE TABLE IF NOT EXISTS document_blocks ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + field_id TEXT NOT NULL, + parent_id TEXT, + kind TEXT NOT NULL, + position INTEGER NOT NULL, + sort_index INTEGER NOT NULL, + addressable INTEGER NOT NULL DEFAULT 1, + content_hash TEXT NOT NULL, + markdown TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'live', + deleted_at_revision INTEGER, + recovered_at_revision INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS document_blocks_field_state_sort_idx + ON document_blocks (field_id, state, sort_index); + CREATE INDEX IF NOT EXISTS document_blocks_parent_idx + ON document_blocks (parent_id)`, + }, ], { table: "content_migrations" }, ); diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index e5a5f0fcb8..bcb8dfb32a 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -1,3 +1,4 @@ +import type { BlocksFieldIdentity } from "./blocks-field-identity"; import type { DocumentPropertyOptions, DocumentPropertyOption, @@ -190,6 +191,7 @@ export interface DocumentProperty { definition: DocumentPropertyDefinition; value: DocumentPropertyValue; editable: boolean; + blocksField?: BlocksFieldIdentity; } export interface DocumentPropertiesResponse { @@ -214,6 +216,7 @@ export interface SetDocumentPropertyRequest { databaseId: string; propertyId: string; value: DocumentPropertyValue; + expectedBlocksFieldRevision?: number; } export interface DuplicateDocumentPropertyRequest { diff --git a/templates/content/shared/blocks-field-identity.spec.ts b/templates/content/shared/blocks-field-identity.spec.ts new file mode 100644 index 0000000000..05b3f12e1c --- /dev/null +++ b/templates/content/shared/blocks-field-identity.spec.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "vitest"; + +import { + blocksFieldId, + exposeBlocksFieldIdentity, + legacyBlocksFieldIdentity, + materializeLegacyBlocksFieldIdentity, + reconcileBlocksFieldIdentity, +} from "./blocks-field-identity.js"; + +function idFactory() { + let next = 0; + return () => `new_block_${++next}`; +} + +describe("Blocks field identity", () => { + it("assigns deterministic but field-scoped identities without changing NFM", () => { + const first = legacyBlocksFieldIdentity({ + documentId: "doc-1", + propertyId: "content", + markdown: "Alpha\nBeta", + }); + const repeated = legacyBlocksFieldIdentity({ + documentId: "doc-1", + propertyId: "content", + markdown: "Alpha\nBeta", + }); + const additional = legacyBlocksFieldIdentity({ + documentId: "doc-1", + propertyId: "notes", + markdown: "Alpha\nBeta", + }); + + expect(repeated).toEqual(first); + expect(additional.fieldId).not.toBe(first.fieldId); + expect(additional.blocks.map((block) => block.id)).not.toEqual( + first.blocks.map((block) => block.id), + ); + expect(first.revision).toBe(0); + expect(first.identityStatus).toBe("legacy"); + }); + + it("preserves IDs through edit, reorder, insertion, split, and merge rules", () => { + const createId = idFactory(); + const initial = materializeLegacyBlocksFieldIdentity({ + documentId: "doc-1", + propertyId: "content", + markdown: "Alpha\nBeta", + }); + const [alphaId, betaId] = initial.blocks.map((block) => block.id); + + const edited = reconcileBlocksFieldIdentity({ + documentId: "doc-1", + propertyId: "content", + previous: initial, + markdown: "Alpha edited\nBeta", + createId, + }); + expect( + edited.blocks + .filter((block) => block.state === "live") + .map((block) => block.id), + ).toEqual([alphaId, betaId]); + + const reordered = reconcileBlocksFieldIdentity({ + documentId: "doc-1", + propertyId: "content", + previous: edited, + markdown: "Beta\nAlpha edited", + createId, + }); + expect( + reordered.blocks + .filter((block) => block.state === "live") + .map((block) => block.id), + ).toEqual([betaId, alphaId]); + + const inserted = reconcileBlocksFieldIdentity({ + documentId: "doc-1", + propertyId: "content", + previous: reordered, + markdown: "Intro\nBeta\nAlpha edited", + createId, + }); + const insertedLive = inserted.blocks.filter( + (block) => block.state === "live", + ); + expect(insertedLive.slice(1).map((block) => block.id)).toEqual([ + betaId, + alphaId, + ]); + + const split = reconcileBlocksFieldIdentity({ + documentId: "doc-1", + propertyId: "content", + previous: inserted, + markdown: "In\ntro\nBeta\nAlpha edited", + createId, + }); + const splitLive = split.blocks.filter((block) => block.state === "live"); + expect(splitLive[0]?.id).toBe(insertedLive[0]?.id); + expect(splitLive[1]?.id).not.toBe(insertedLive[0]?.id); + + const merged = reconcileBlocksFieldIdentity({ + documentId: "doc-1", + propertyId: "content", + previous: split, + markdown: "Intro\nBeta\nAlpha edited", + createId, + }); + const mergedPublic = exposeBlocksFieldIdentity( + merged, + "Intro\nBeta\nAlpha edited", + ); + expect(mergedPublic.blocks[0]?.id).toBe(splitLive[0]?.id); + expect(mergedPublic.tombstones).toContainEqual( + expect.objectContaining({ id: splitLive[1]?.id }), + ); + }); + + it("reserves a deleted ID and recovers it only for an exact tombstoned block", () => { + const createId = idFactory(); + const initial = materializeLegacyBlocksFieldIdentity({ + documentId: "doc-1", + propertyId: "content", + markdown: "Keep\nRecover me", + }); + const recoverId = initial.blocks[1]!.id; + const deleted = reconcileBlocksFieldIdentity({ + documentId: "doc-1", + propertyId: "content", + previous: initial, + markdown: "Keep", + createId, + }); + expect( + exposeBlocksFieldIdentity(deleted, "Keep").tombstones, + ).toContainEqual( + expect.objectContaining({ id: recoverId, deletedAtRevision: 1 }), + ); + + const recovered = reconcileBlocksFieldIdentity({ + documentId: "doc-1", + propertyId: "content", + previous: deleted, + markdown: "Keep\nRecover me", + createId, + }); + expect( + exposeBlocksFieldIdentity(recovered, "Keep\nRecover me").blocks.map( + (block) => block.id, + ), + ).toContain(recoverId); + expect( + exposeBlocksFieldIdentity(recovered, "Keep\nRecover me").tombstones, + ).not.toContainEqual(expect.objectContaining({ id: recoverId })); + }); + + it("preserves honest IDs when siblings are reordered and edited together", () => { + const initial = materializeLegacyBlocksFieldIdentity({ + documentId: "doc-1", + propertyId: "content", + markdown: "Alpha paragraph\nBeta paragraph", + }); + const [alphaId, betaId] = initial.blocks.map((block) => block.id); + const reconciled = reconcileBlocksFieldIdentity({ + documentId: "doc-1", + propertyId: "content", + previous: initial, + markdown: "Beta paragraph edited\nAlpha paragraph edited", + createId: idFactory(), + }); + + expect( + reconciled.blocks + .filter((block) => block.state === "live") + .map((block) => block.id), + ).toEqual([betaId, alphaId]); + }); + + it("represents nested live NFM block kinds as an ordered parented graph", () => { + const identity = legacyBlocksFieldIdentity({ + documentId: "doc-kinds", + propertyId: "content", + markdown: [ + "# Heading", + "> Quote", + "- List item", + "\t- Nested item", + "[ ] Task", + "---", + "```ts", + "const stable = true", + "```", + '', + "\tInside", + "", + ].join("\n"), + }); + const kinds = new Set(identity.blocks.map((block) => block.kind)); + + expect(kinds).toEqual( + expect.objectContaining( + new Set([ + "heading", + "blockquote", + "bulletList", + "listItem", + "paragraph", + "taskList", + "taskItem", + "horizontalRule", + "codeBlock", + "notionCallout", + ]), + ), + ); + expect(identity.blocks.some((block) => block.parentId !== null)).toBe(true); + expect(new Set(identity.blocks.map((block) => block.id)).size).toBe( + identity.blocks.length, + ); + expect(identity.fieldId).toBe(blocksFieldId("doc-kinds", "content")); + }); +}); diff --git a/templates/content/shared/blocks-field-identity.ts b/templates/content/shared/blocks-field-identity.ts new file mode 100644 index 0000000000..f48a2bb746 --- /dev/null +++ b/templates/content/shared/blocks-field-identity.ts @@ -0,0 +1,525 @@ +import { docToNfm, nfmToDoc, type PMNode } from "./nfm.js"; + +export const BLOCKS_FIELD_IDENTITY_VERSION = 1; + +export type BlocksFieldIdentityStatus = "legacy" | "materialized" | "stale"; + +export interface BlocksFieldBlock { + id: string; + parentId: string | null; + kind: string; + position: number; + addressable: boolean; +} + +export interface BlocksFieldTombstone { + id: string; + kind: string; + deletedAtRevision: number; +} + +export interface BlocksFieldIdentity { + version: typeof BLOCKS_FIELD_IDENTITY_VERSION; + fieldId: string; + revision: number; + contentHash: string; + identityStatus: BlocksFieldIdentityStatus; + blocks: BlocksFieldBlock[]; + tombstones: BlocksFieldTombstone[]; + recoveryMode: "editor-undo-with-tombstones"; +} + +export interface StoredBlocksFieldBlock extends BlocksFieldBlock { + contentHash: string; + markdown: string; + deletedAtRevision: number | null; + recoveredAtRevision: number | null; + state: "live" | "deleted"; +} + +export interface StoredBlocksFieldIdentity { + fieldId: string; + revision: number; + contentHash: string; + blocks: StoredBlocksFieldBlock[]; +} + +interface BlockSnapshot { + path: string; + parentPath: string | null; + kind: string; + position: number; + addressable: boolean; + contentHash: string; + markdown: string; + 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 NON_ADDRESSABLE_NODE_TYPES = new Set([ + "bulletList", + "orderedList", + "taskList", + "tableRow", + "tableHeader", + "tableCell", +]); + +function hashString(value: string): string { + let first = 0x811c9dc5; + let second = 0x9e3779b9; + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + first ^= code; + first = Math.imul(first, 0x01000193); + second ^= code + index; + second = Math.imul(second, 0x85ebca6b); + } + return `${(first >>> 0).toString(16).padStart(8, "0")}${(second >>> 0) + .toString(16) + .padStart(8, "0")}`; +} + +export function blocksFieldId(documentId: string, propertyId: string): string { + return `blocks_field_${hashString(`${documentId}\0${propertyId}`)}`; +} + +export function blocksContentHash(markdown: string): string { + return `nfm_${hashString(markdown)}`; +} + +function stableNodeValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableNodeValue); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .filter(([key]) => key !== "blockId") + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, stableNodeValue(child)]), + ); +} + +function nodeMarkdown(node: PMNode): string { + return docToNfm({ type: "doc", content: [node] }); +} + +function snapshotMarkdown(markdown: string): BlockSnapshot[] { + const snapshots: BlockSnapshot[] = []; + const doc = nfmToDoc(markdown); + + function visit(nodes: PMNode[] | undefined, parentPath: string | null) { + let blockPosition = 0; + for (let index = 0; index < (nodes?.length ?? 0); index++) { + const node = nodes![index]!; + if (!BLOCK_NODE_TYPES.has(node.type)) continue; + const path = + parentPath === null + ? `${blockPosition}` + : `${parentPath}.${blockPosition}`; + const canonical = JSON.stringify(stableNodeValue(node)); + snapshots.push({ + path, + parentPath, + kind: node.type, + position: blockPosition, + addressable: !NON_ADDRESSABLE_NODE_TYPES.has(node.type), + contentHash: `block_${hashString(canonical)}`, + markdown: nodeMarkdown(node), + preferredId: + node.type === "registryBlock" && + typeof node.attrs?.blockId === "string" + ? node.attrs.blockId + : null, + }); + visit(node.content, path); + blockPosition++; + } + } + + visit(doc.content, null); + return snapshots; +} + +function deterministicBlockId( + fieldId: string, + snapshot: Pick, +): string { + return `block_${hashString( + `${fieldId}\0${snapshot.path}\0${snapshot.kind}\0${snapshot.contentHash}`, + )}`; +} + +function publicIdentity( + stored: StoredBlocksFieldIdentity, + identityStatus: BlocksFieldIdentityStatus, +): BlocksFieldIdentity { + return { + version: BLOCKS_FIELD_IDENTITY_VERSION, + fieldId: stored.fieldId, + revision: stored.revision, + contentHash: stored.contentHash, + identityStatus, + blocks: stored.blocks + .filter((block) => block.state === "live") + .map(({ id, parentId, kind, position, addressable }) => ({ + id, + parentId, + kind, + position, + addressable, + })), + tombstones: stored.blocks + .filter( + ( + block, + ): block is StoredBlocksFieldBlock & { + deletedAtRevision: number; + } => block.state === "deleted" && block.deletedAtRevision !== null, + ) + .map(({ id, kind, deletedAtRevision }) => ({ + id, + kind, + deletedAtRevision, + })), + recoveryMode: "editor-undo-with-tombstones", + }; +} + +export function legacyBlocksFieldIdentity(args: { + documentId: string; + propertyId: string; + markdown: string; +}): BlocksFieldIdentity { + const fieldId = blocksFieldId(args.documentId, args.propertyId); + const snapshots = snapshotMarkdown(args.markdown); + const idByPath = new Map(); + const usedIds = new Set(); + const blocks: StoredBlocksFieldBlock[] = snapshots.map((snapshot) => { + const preferred = snapshot.preferredId; + const id = + preferred && !usedIds.has(preferred) + ? preferred + : deterministicBlockId(fieldId, snapshot); + usedIds.add(id); + idByPath.set(snapshot.path, id); + return { + id, + parentId: snapshot.parentPath + ? (idByPath.get(snapshot.parentPath) ?? null) + : null, + kind: snapshot.kind, + position: snapshot.position, + addressable: snapshot.addressable, + contentHash: snapshot.contentHash, + markdown: snapshot.markdown, + state: "live", + deletedAtRevision: null, + recoveredAtRevision: null, + }; + }); + return publicIdentity( + { + fieldId, + revision: 0, + contentHash: blocksContentHash(args.markdown), + blocks, + }, + "legacy", + ); +} + +function uniqueIndexByKey( + values: T[], + key: (value: T) => string, +): Map { + const counts = new Map(); + const index = new Map(); + values.forEach((value, valueIndex) => { + const valueKey = key(value); + counts.set(valueKey, (counts.get(valueKey) ?? 0) + 1); + index.set(valueKey, valueIndex); + }); + for (const [valueKey, count] of counts) { + if (count !== 1) index.delete(valueKey); + } + return index; +} + +function characterBigrams(value: string): Set { + const normalized = value.toLocaleLowerCase().replace(/\s+/g, " ").trim(); + if (normalized.length < 2) return new Set(normalized ? [normalized] : []); + const result = new Set(); + for (let index = 0; index < normalized.length - 1; index++) { + result.add(normalized.slice(index, index + 2)); + } + return result; +} + +function editSimilarity(left: string, right: string): number { + const leftBigrams = characterBigrams(left); + const rightBigrams = characterBigrams(right); + if (leftBigrams.size === 0 || rightBigrams.size === 0) return 0; + let shared = 0; + for (const bigram of leftBigrams) { + if (rightBigrams.has(bigram)) shared++; + } + return (2 * shared) / (leftBigrams.size + rightBigrams.size); +} + +export function reconcileBlocksFieldIdentity(args: { + documentId: string; + propertyId: string; + previous: StoredBlocksFieldIdentity; + markdown: string; + createId: () => string; +}): StoredBlocksFieldIdentity { + const nextRevision = args.previous.revision + 1; + const snapshots = snapshotMarkdown(args.markdown); + const previousLive = args.previous.blocks.filter( + (block) => block.state === "live", + ); + const previousDeleted = args.previous.blocks.filter( + (block) => block.state === "deleted", + ); + const matchedPrevious = new Set(); + const assigned = new Map(); + const usedIds = new Set(args.previous.blocks.map((block) => block.id)); + + const previousExact = uniqueIndexByKey( + previousLive, + (block) => `${block.kind}\0${block.contentHash}`, + ); + const nextExact = uniqueIndexByKey( + snapshots, + (snapshot) => `${snapshot.kind}\0${snapshot.contentHash}`, + ); + for (const [key, previousIndex] of previousExact) { + const nextIndex = nextExact.get(key); + if (nextIndex === undefined) continue; + matchedPrevious.add(previousIndex); + assigned.set(nextIndex, previousLive[previousIndex]!); + } + + // Split keeps the leading fragment's ID; merge keeps the receiving block's + // ID. These are the only cardinality-changing cases where position conveys + // more identity than text similarity. + const unmatchedKinds = new Set([ + ...previousLive + .filter((_block, index) => !matchedPrevious.has(index)) + .map((block) => block.kind), + ...snapshots + .filter((_snapshot, index) => !assigned.has(index)) + .map((snapshot) => snapshot.kind), + ]); + for (const kind of unmatchedKinds) { + const previousIndexes = previousLive.flatMap((block, index) => + !matchedPrevious.has(index) && block.kind === kind ? [index] : [], + ); + const nextIndexes = snapshots.flatMap((snapshot, index) => + !assigned.has(index) && snapshot.kind === kind ? [index] : [], + ); + if (previousIndexes.length !== 1 && nextIndexes.length !== 1) { + continue; + } + if (previousIndexes.length === 0 || nextIndexes.length === 0) continue; + const samePosition = nextIndexes.find( + (nextIndex) => + snapshots[nextIndex]!.position === + previousLive[previousIndexes[0]!]!.position, + ); + if (samePosition === undefined) continue; + matchedPrevious.add(previousIndexes[0]!); + assigned.set(samePosition, previousLive[previousIndexes[0]!]!); + } + + // A reorder and a text edit can happen in one editor transaction. Pair only + // mutual, sufficiently similar best matches before considering position; an + // ambiguous match is left for the conservative positional rule below. + const bestNextForPrevious = new Map< + number, + { index: number; score: number } + >(); + const bestPreviousForNext = new Map< + number, + { index: number; score: number } + >(); + for ( + let previousIndex = 0; + previousIndex < previousLive.length; + previousIndex++ + ) { + if (matchedPrevious.has(previousIndex)) continue; + const previous = previousLive[previousIndex]!; + for (let nextIndex = 0; nextIndex < snapshots.length; nextIndex++) { + if (assigned.has(nextIndex)) continue; + const snapshot = snapshots[nextIndex]!; + if (previous.kind !== snapshot.kind) continue; + const score = editSimilarity(previous.markdown, snapshot.markdown); + if (score < 0.45) continue; + const previousBest = bestNextForPrevious.get(previousIndex); + if (!previousBest || score > previousBest.score) { + bestNextForPrevious.set(previousIndex, { index: nextIndex, score }); + } + const nextBest = bestPreviousForNext.get(nextIndex); + if (!nextBest || score > nextBest.score) { + bestPreviousForNext.set(nextIndex, { index: previousIndex, score }); + } + } + } + for (const [previousIndex, nextBest] of bestNextForPrevious) { + const previousBest = bestPreviousForNext.get(nextBest.index); + if (previousBest?.index !== previousIndex) continue; + matchedPrevious.add(previousIndex); + assigned.set(nextBest.index, previousLive[previousIndex]!); + } + + for (let nextIndex = 0; nextIndex < snapshots.length; nextIndex++) { + if (assigned.has(nextIndex)) continue; + const snapshot = snapshots[nextIndex]!; + const samePosition = previousLive.findIndex( + (block, previousIndex) => + !matchedPrevious.has(previousIndex) && + block.kind === snapshot.kind && + block.position === snapshot.position, + ); + if (samePosition !== -1) { + matchedPrevious.add(samePosition); + assigned.set(nextIndex, previousLive[samePosition]!); + continue; + } + const sameKind = previousLive.findIndex( + (block, previousIndex) => + !matchedPrevious.has(previousIndex) && block.kind === snapshot.kind, + ); + if (sameKind !== -1) { + matchedPrevious.add(sameKind); + assigned.set(nextIndex, previousLive[sameKind]!); + } + } + + const recoverable = uniqueIndexByKey( + previousDeleted, + (block) => `${block.kind}\0${block.contentHash}`, + ); + const recoveredIds = new Set(); + const idByPath = new Map(); + const nextBlocks = snapshots.map((snapshot, nextIndex) => { + let previous = assigned.get(nextIndex); + if (!previous) { + const recoveredIndex = recoverable.get( + `${snapshot.kind}\0${snapshot.contentHash}`, + ); + if (recoveredIndex !== undefined) { + previous = previousDeleted[recoveredIndex]; + if (previous) recoveredIds.add(previous.id); + } + } + let id = previous?.id ?? snapshot.preferredId ?? args.createId(); + while (usedIds.has(id) && id !== previous?.id) id = args.createId(); + usedIds.add(id); + idByPath.set(snapshot.path, id); + return { + id, + parentId: snapshot.parentPath + ? (idByPath.get(snapshot.parentPath) ?? null) + : null, + kind: snapshot.kind, + position: snapshot.position, + addressable: snapshot.addressable, + contentHash: snapshot.contentHash, + markdown: snapshot.markdown, + state: "live" as const, + deletedAtRevision: null, + recoveredAtRevision: recoveredIds.has(id) ? nextRevision : null, + }; + }); + + const deletedNow = previousLive + .filter((_block, index) => !matchedPrevious.has(index)) + .map((block) => ({ + ...block, + state: "deleted" as const, + deletedAtRevision: nextRevision, + recoveredAtRevision: null, + })); + const retainedTombstones = previousDeleted.filter( + (block) => !recoveredIds.has(block.id), + ); + + return { + fieldId: args.previous.fieldId, + revision: nextRevision, + contentHash: blocksContentHash(args.markdown), + blocks: [...nextBlocks, ...retainedTombstones, ...deletedNow], + }; +} + +export function exposeBlocksFieldIdentity( + stored: StoredBlocksFieldIdentity, + markdown: string, +): BlocksFieldIdentity { + const currentHash = blocksContentHash(markdown); + if (currentHash === stored.contentHash) { + return publicIdentity(stored, "materialized"); + } + let provisionalIndex = 0; + const reconciled = reconcileBlocksFieldIdentity({ + documentId: "stale-read", + propertyId: stored.fieldId, + previous: stored, + markdown, + createId: () => + `block_${hashString(`${stored.fieldId}\0${markdown}\0${provisionalIndex++}`)}`, + }); + return publicIdentity({ ...reconciled, revision: stored.revision }, "stale"); +} + +export function materializeLegacyBlocksFieldIdentity(args: { + documentId: string; + propertyId: string; + markdown: string; +}): StoredBlocksFieldIdentity { + const publicState = legacyBlocksFieldIdentity(args); + const snapshots = snapshotMarkdown(args.markdown); + return { + fieldId: publicState.fieldId, + revision: 0, + contentHash: publicState.contentHash, + blocks: publicState.blocks.map((block, index) => ({ + ...block, + contentHash: snapshots[index]!.contentHash, + markdown: snapshots[index]!.markdown, + state: "live", + deletedAtRevision: null, + recoveredAtRevision: null, + })), + }; +} diff --git a/templates/content/shared/document-export.spec.ts b/templates/content/shared/document-export.spec.ts index 070bee1ec8..0e8a15ee1b 100644 --- a/templates/content/shared/document-export.spec.ts +++ b/templates/content/shared/document-export.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { legacyBlocksFieldIdentity } from "./blocks-field-identity"; import { buildDocumentExport, exportFilename, @@ -8,6 +9,56 @@ import { import { KATEX_STYLESHEET_URL } from "./math-rendering"; describe("document export", () => { + it("carries ordered Blocks fields in a non-rendering identity manifest", () => { + const markdown = "Alpha\nBeta"; + const blocksFields = [ + { + propertyId: "content", + name: "Content", + position: 0, + markdown, + identity: legacyBlocksFieldIdentity({ + documentId: "doc_123", + propertyId: "content", + markdown, + }), + }, + { + propertyId: "notes", + name: "Notes", + position: 1, + markdown: "Private notes", + identity: legacyBlocksFieldIdentity({ + documentId: "doc_123", + propertyId: "notes", + markdown: "Private notes", + }), + }, + ]; + const exported = buildDocumentExport({ + id: "doc_123", + title: "Identity export", + content: markdown, + format: "markdown", + blocksFields, + }); + + expect(exported.blocksFields).toEqual(blocksFields); + expect(exported.content).toContain("\n`; + } return { id: input.id, @@ -655,5 +685,6 @@ export function buildDocumentExport( mimeType: MIME_BY_FORMAT[input.format], content, print: input.format === "pdf", + ...(input.blocksFields?.length ? { blocksFields: input.blocksFields } : {}), }; } From da1846f55feea1316da3793735482e477679ec25 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:16:09 -0400 Subject: [PATCH 02/16] feat(content): make row mutations reliable --- .changeset/tidy-content-row-conflicts.md | 5 + package.json | 3 +- packages/core/src/action.spec.ts | 17 + packages/core/src/action.ts | 43 + packages/core/src/index.ts | 3 + .../core/src/server/action-routes.spec.ts | 33 + packages/core/src/server/action-routes.ts | 12 +- .../content/.agents/skills/content/SKILL.md | 4 +- .../document-editing/references/databases.md | 24 +- .../content/actions/_database-row-mutation.ts | 1502 +++++++++++++++++ templates/content/actions/_database-utils.ts | 44 + templates/content/actions/_property-utils.ts | 5 +- .../content/actions/add-database-item.ts | 302 +--- .../content/actions/blocks-seeding.db.test.ts | 11 +- .../actions/configure-document-property.ts | 163 +- .../content-database-lifecycle.db.test.ts | 16 +- .../content/actions/content-spaces.db.test.ts | 13 +- .../database-row-batch-actions.db.test.ts | 74 +- .../actions/delete-document-property.ts | 10 + .../content/actions/set-document-property.ts | 79 +- .../actions/space-aware-writers.db.test.ts | 24 +- .../content/actions/update-database-item.ts | 62 + .../upsert-database-item-by-key.db.test.ts | 1210 +++++-------- .../actions/upsert-database-item-by-key.ts | 839 +-------- .../editor/database/DatabaseView.tsx | 16 +- .../content/app/hooks/use-content-database.ts | 65 +- ...now-validate-exact-schemas-and-safe-ret.md | 6 + templates/content/parity/matrix.md | 2 +- templates/content/parity/matrix.ts | 2 + templates/content/server/agent-card.test.ts | 2 + templates/content/server/db/schema.ts | 42 +- templates/content/server/plugins/db.ts | 30 + templates/content/shared/api.ts | 77 +- 33 files changed, 2860 insertions(+), 1880 deletions(-) create mode 100644 .changeset/tidy-content-row-conflicts.md create mode 100644 templates/content/actions/_database-row-mutation.ts create mode 100644 templates/content/actions/update-database-item.ts create mode 100644 templates/content/changelog/2026-08-10-database-row-actions-now-validate-exact-schemas-and-safe-ret.md diff --git a/.changeset/tidy-content-row-conflicts.md b/.changeset/tidy-content-row-conflicts.md new file mode 100644 index 0000000000..b5923c8346 --- /dev/null +++ b/.changeset/tidy-content-row-conflicts.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Preserve typed action contract conflicts across the shared HTTP action transport. diff --git a/package.json b/package.json index c38892c0d4..fc77333dfb 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "fix:imports": "oxfmt --write .", "test": "tsx scripts/workspace-run.ts test", "test:fast": "tsx scripts/workspace-run.ts test -- --exclude \"**/*.db.test.ts\" --exclude \"**/*.integration.spec.ts\" --exclude \"**/*.integration.test.ts\" --exclude \"**/*.e2e.spec.ts\" --exclude \"**/*.e2e.test.ts\" --exclude \"**/e2e/**\" --exclude \"**/*.live.spec.ts\" --exclude \"**/*.live.test.ts\" --exclude \"**/*.perf.spec.ts\" --exclude \"**/*.perf.test.ts\" --exclude \"**/create-e2e.spec.ts\"", - "test:content-db": "pnpm --filter content exec vitest --run actions/bind-content-database-source-field.db.test.ts actions/blocks-seeding.db.test.ts actions/builder-source-review-gates.db.test.ts actions/content-database-lifecycle.db.test.ts actions/database-row-batch-actions.db.test.ts actions/list-content-databases.db.test.ts actions/migrate-content-database-rows.db.test.ts actions/move-document.db.test.ts actions/resync-content-database-source.db.test.ts actions/slack-correction-identity.db.test.ts actions/stage-builder-source-bulk-update.db.test.ts actions/submit-content-database-form.db.test.ts actions/update-document.db.test.ts --config vitest.config.ts", + "test:content-db": "pnpm --filter content exec vitest --run actions/bind-content-database-source-field.db.test.ts actions/blocks-seeding.db.test.ts actions/builder-source-review-gates.db.test.ts actions/content-database-lifecycle.db.test.ts actions/database-row-batch-actions.db.test.ts actions/list-content-databases.db.test.ts actions/migrate-content-database-rows.db.test.ts actions/move-document.db.test.ts actions/resync-content-database-source.db.test.ts actions/slack-correction-identity.db.test.ts actions/stage-builder-source-bulk-update.db.test.ts actions/submit-content-database-form.db.test.ts actions/update-document.db.test.ts actions/upsert-database-item-by-key.db.test.ts --config vitest.config.ts", + "test:content-row-mutations-postgres": "test -n \"$CONTENT_ROW_MUTATION_POSTGRES_URL\" && pnpm --filter content exec vitest --run actions/upsert-database-item-by-key.db.test.ts --config vitest.config.ts", "test:content-db-postgres": "pnpm --filter content exec vitest --run actions/migrate-content-database-rows.postgres.integration.test.ts --config vitest.config.ts", "test:core-integration": "pnpm --filter @agent-native/core exec vitest --run src/agent/engine/translate-ai-sdk.integration.spec.ts src/agent/run-loop-with-resume.integration.spec.ts src/client/extensions/AgentNativeExtensionFrame.e2e.spec.ts src/client/session-replay-iframe.e2e.spec.ts src/scripts/db/migrate-encrypt-credentials.e2e.spec.ts src/scripts/db/scope-isolation.e2e.spec.ts src/server/csrf-plugin-ordering.integration.spec.ts src/server/embedded.integration.spec.ts --passWithNoTests", "test:plan-e2e": "pnpm --filter plan exec vitest --run actions/create-visual-recap.e2e.spec.ts --passWithNoTests", diff --git a/packages/core/src/action.spec.ts b/packages/core/src/action.spec.ts index f014da6b6d..838c994b46 100644 --- a/packages/core/src/action.spec.ts +++ b/packages/core/src/action.spec.ts @@ -3,10 +3,27 @@ import { z } from "zod"; import { defineAction, + ActionContractError, + isActionContractError, AgentActionStopError, isAgentActionStopError, } from "./action.js"; +describe("ActionContractError", () => { + it("carries only explicitly safe structured contract details", () => { + const error = new ActionContractError("Stale schema", { + errorCode: "SCHEMA_REVISION_CONFLICT", + details: { expected: "before", actual: "after" }, + }); + expect(isActionContractError(error)).toBe(true); + expect(error).toMatchObject({ + statusCode: 409, + errorCode: "SCHEMA_REVISION_CONFLICT", + details: { expected: "before", actual: "after" }, + }); + }); +}); + // Uses the legacy `parameters` mode so we don't need to pull in zod as a test // dep — the readOnly inference logic is independent of the schema path. describe("defineAction", () => { diff --git a/packages/core/src/action.ts b/packages/core/src/action.ts index 24eff91b0b..483a1ea134 100644 --- a/packages/core/src/action.ts +++ b/packages/core/src/action.ts @@ -161,6 +161,49 @@ export interface AgentActionStopOptions { toolResult?: string; } +export interface ActionContractErrorOptions { + /** Stable machine-readable code safe to expose on every action transport. */ + errorCode: string; + /** Safe structured context for callers. Never include secrets or raw driver errors. */ + details?: Record; + /** HTTP status for the action route. Contract conflicts normally use 409. */ + statusCode?: number; +} + +/** + * A deterministic, caller-correctable action contract failure. + * + * Unlike an ordinary Error, its code and explicitly safe details survive the + * framework HTTP transport. Internal failures remain generic 500 responses. + */ +export class ActionContractError extends Error { + readonly actionContractError = true; + readonly errorCode: string; + readonly details?: Record; + readonly statusCode: number; + + constructor(message: string, options: ActionContractErrorOptions) { + super(message); + this.name = "ActionContractError"; + this.errorCode = options.errorCode; + this.details = options.details; + this.statusCode = options.statusCode ?? 409; + } +} + +export function isActionContractError( + error: unknown, +): error is ActionContractError { + return ( + error instanceof ActionContractError || + (!!error && + typeof error === "object" && + (error as { actionContractError?: unknown }).actionContractError === + true && + typeof (error as { errorCode?: unknown }).errorCode === "string") + ); +} + /** * Throw from an action when the agent should stop the current turn instead of * feeding the failure back to the model for another retry. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4ce67a4b5f..9c719beff3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,6 +24,9 @@ export { } from "./agent/index.js"; export { defineAction, + ActionContractError, + isActionContractError, + type ActionContractErrorOptions, AgentActionStopError, isAgentActionStopError, type ActionDefinition, diff --git a/packages/core/src/server/action-routes.spec.ts b/packages/core/src/server/action-routes.spec.ts index 7859472f4c..5be01ba053 100644 --- a/packages/core/src/server/action-routes.spec.ts +++ b/packages/core/src/server/action-routes.spec.ts @@ -333,6 +333,39 @@ describe("mountActionRoutes", () => { expect(event._status).toBe(403); }); + it("preserves typed action contract conflicts without exposing arbitrary errors", async () => { + const { ActionContractError } = await import("../action.js"); + const { mountActionRoutes } = await import("./action-routes.js"); + const mounted: Array<{ path: string; handler: any }> = []; + const nitroApp = { + use: vi.fn((path: string, handler: any) => + mounted.push({ path, handler }), + ), + }; + const conflict = new ActionContractError("Stale schema", { + errorCode: "SCHEMA_REVISION_CONFLICT", + details: { expected: "before", actual: "after" }, + }); + const actions = { + updateItem: { + run: vi.fn().mockRejectedValue(conflict), + http: { method: "POST" as const }, + }, + }; + mountActionRoutes(nitroApp, actions as any, { + getOwnerFromEvent: async () => "owner@example.com", + }); + const event = { _method: "POST", req: { json: async () => ({}) } }; + const result = await mounted[0].handler(event); + + expect(event._status).toBe(409); + expect(result).toEqual({ + error: "Stale schema", + errorCode: "SCHEMA_REVISION_CONFLICT", + details: { expected: "before", actual: "after" }, + }); + }); + it("captures uncategorized action failures with low-cardinality context", async () => { const { mountActionRoutes } = await import("./action-routes.js"); const { registerErrorCaptureProvider } = await import("./capture-error.js"); diff --git a/packages/core/src/server/action-routes.ts b/packages/core/src/server/action-routes.ts index 1d03022675..04bbd70e30 100644 --- a/packages/core/src/server/action-routes.ts +++ b/packages/core/src/server/action-routes.ts @@ -10,7 +10,7 @@ import { } from "h3"; import { verifyA2ATokenWithClaims } from "../a2a-claims.js"; -import { isAgentActionStopError } from "../action.js"; +import { isActionContractError, isAgentActionStopError } from "../action.js"; import type { ActionEntry } from "../agent/production-agent.js"; import { declaresFeatureFlagDelegation } from "../feature-flags/a2a-action-route.js"; import { resolveOrgIdForEmail } from "../org/context.js"; @@ -679,7 +679,15 @@ export function mountActionRoutes( isAgentActionStopError(err) || (explicitStatus !== undefined && explicitStatus < 500); if (isUserFacing) { - return { error: msg }; + return isActionContractError(err) + ? { + error: msg, + errorCode: err.errorCode, + ...(err.details === undefined + ? {} + : { details: err.details }), + } + : { error: msg }; } const requestId = getHttpRequestTelemetryId(event); const captureId = captureError(err, { diff --git a/templates/content/.agents/skills/content/SKILL.md b/templates/content/.agents/skills/content/SKILL.md index 99e8e45d24..72da1fec3c 100644 --- a/templates/content/.agents/skills/content/SKILL.md +++ b/templates/content/.agents/skills/content/SKILL.md @@ -121,8 +121,8 @@ For a named intake workflow: when the database has no form contract and all required values have already been confirmed. 8. Treat submission as complete only when the successful result includes a - `createdDocumentId` and verification. Return the exact `url` or `urlPath` - from the result. The canonical Content row route is `/page/`; + stable row IDs and verified read-back. Return the exact `url` or `urlPath` + from the result. The canonical Content row route is `/page/`; never invent a different path, slug, ID, or host. When the user supplies a complete description in one message, do not force a diff --git a/templates/content/.agents/skills/document-editing/references/databases.md b/templates/content/.agents/skills/document-editing/references/databases.md index 98dc606f12..35dee4f355 100644 --- a/templates/content/.agents/skills/document-editing/references/databases.md +++ b/templates/content/.agents/skills/document-editing/references/databases.md @@ -117,7 +117,9 @@ checkbox filters as initial property values, resolving option labels back to stable option IDs for select, status, and multi-select filters, so a row created under "Status is Published" remains visible instead of immediately disappearing. Agents can mirror that behavior by passing -`--propertyValues '{"propertyId":"value"}'` to `add-database-item`. Filter +the discovered property IDs in `propertyValues` to `add-database-item`. The +action also requires the exact space/database/backing-page target, current +schema revision, and a caller-stable idempotency key. Filter controls are type-aware: option properties choose from their configured options, option value editors can search existing options or create a new option from the typed query, and property settings can rename option labels @@ -168,8 +170,10 @@ memberships, but they are references: moving one never reparents, transfers, or changes access to the referenced page. Files sidebar Custom order is different again: persist it per user and per database view with `update-content-database-personal-view`, without changing the shared Files -membership order. Creating a database row returns the created item IDs and -opens the new row page in the side preview. Duplicating a database row +membership order. Creating a database row returns a receipt with stable item +and document IDs, row link, revisions, affected fields, idempotency outcome, +and verified read-back, then opens the new row page in the side preview. +Duplicating a database row returns the duplicate item IDs and opens the copied row in the side preview so users can continue editing the new page immediately, including from table, list, and gallery row action menus. Board, calendar, and timeline @@ -227,13 +231,25 @@ property definition. Use `create-content-database`, `create-inline-content-database`, `get-content-database`, `list-trashed-content-databases`, -`restore-content-database`, `add-database-item`, `duplicate-database-item`, +`restore-content-database`, `add-database-item`, `update-database-item`, +`upsert-database-item-by-key`, `duplicate-database-item`, `duplicate-database-items`, `remove-database-items`, `move-database-item`, `update-content-database-view`, `list-document-properties`, `configure-document-property`, `set-document-property`, `duplicate-document-property`, and `delete-document-property`; do not edit property rows or view config via raw SQL when an action can do it. +Read `get-content-database.mutationContract` immediately before a single-row +mutation. Pass its exact target and schema revision to create, exact item and +document IDs plus the current row revision to sparse update, and a fresh +idempotency key for each intended effect. Reusing the same key with the same +payload replays the durable receipt; reusing it with a different payload fails. +Configure at most one ordinary text property as the database's natural key, +then use `upsert-database-item-by-key` with that property. Natural-key upsert +never accepts an arbitrary property name or silently chooses a field. Blocks, +computed, system, source-managed, unknown, and relation properties are not +writable through these actions; use their owning surfaces instead. + For a bounded migration that must rewrite every existing row body while adding new property definitions and values, use `migrate-content-database-rows` rather than looping the single-row actions. Its `validate` phase is read-only; `apply` diff --git a/templates/content/actions/_database-row-mutation.ts b/templates/content/actions/_database-row-mutation.ts new file mode 100644 index 0000000000..8774e56395 --- /dev/null +++ b/templates/content/actions/_database-row-mutation.ts @@ -0,0 +1,1502 @@ +import { createHash } from "node:crypto"; + +import { ActionContractError } from "@agent-native/core"; +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { assertAccess } from "@agent-native/core/sharing"; +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import type { + ContentDatabaseMutationContract, + ContentDatabaseRowMutationReceipt, + ContentDatabaseRowMutationResult, +} from "../shared/api.js"; +import { + isBlocksPropertyType, + isComputedPropertyType, + parsePropertyOptions, + parsePropertyValue, + serializePropertyValue, + type DocumentPropertyDateValue, + type DocumentPropertyType, + type DocumentPropertyValue, +} from "../shared/properties.js"; +import { + lockContentDatabaseMutation, + touchContentDatabase, +} from "./_content-database-mutation-lock.js"; +import { ensureDocumentFilesMembership } from "./_content-files.js"; +import { + databaseItemsPositionScope, + documentsPositionScope, + withPositionLock, +} from "./_position-utils.js"; +import { nanoid } from "./_property-utils.js"; + +export const databaseMutationTargetSchema = z.object({ + authorityScope: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("personal"), id: z.string().min(1) }), + z.object({ kind: z.literal("organization"), id: z.string().min(1) }), + ]), + spaceId: z.string().min(1).describe("Exact Content space ID"), + databaseId: z.string().min(1).describe("Exact Content database ID"), + databaseDocumentId: z + .string() + .min(1) + .describe("Exact page ID backing the Content database"), +}); + +export const databaseMutationEnvelopeSchema = z.object({ + target: databaseMutationTargetSchema, + expectedSchemaRevision: z + .string() + .min(1) + .describe("Schema revision returned by get-content-database"), + idempotencyKey: z.string().min(1).max(200), +}); + +export type DatabaseMutationTarget = z.infer< + typeof databaseMutationTargetSchema +>; + +type DatabaseRow = typeof schema.contentDatabases.$inferSelect; +type DefinitionRow = typeof schema.documentPropertyDefinitions.$inferSelect; +type Db = ReturnType; + +interface MutationContext { + database: DatabaseRow; + databaseDocument: typeof schema.documents.$inferSelect; + definitions: DefinitionRow[]; + sourceManagedPropertyIds: Set; + schemaRevision: string; +} + +interface RowSnapshot { + item: typeof schema.contentDatabaseItems.$inferSelect; + document: typeof schema.documents.$inferSelect; + values: Map; + revision: string; +} + +export type DatabaseRowMutationOperation = "create" | "update" | "upsert"; + +export interface CreateDatabaseRowMutationInput { + target: DatabaseMutationTarget; + expectedSchemaRevision: string; + idempotencyKey: string; + title?: string; + propertyValues?: Record; +} + +export interface UpdateDatabaseRowMutationInput extends CreateDatabaseRowMutationInput { + itemId: string; + documentId: string; + expectedRowRevision: string; +} + +export interface UpsertDatabaseRowMutationInput extends CreateDatabaseRowMutationInput { + keyValue: string; + expectedRowRevision: string | null; +} + +function canonical(value: unknown): string { + if (value === undefined) return "null"; + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + return `{${Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`) + .join(",")}}`; +} + +function digest(value: unknown): string { + return `sha256:${createHash("sha256").update(canonical(value)).digest("hex")}`; +} + +export function databaseRowRevision(args: { + itemId: string; + documentId: string; + title: string; + values: Array<{ propertyId: string; value: DocumentPropertyValue }>; +}) { + return digest({ + itemId: args.itemId, + documentId: args.documentId, + title: args.title, + values: args.values + .filter((entry) => entry.value !== null) + .sort((left, right) => left.propertyId.localeCompare(right.propertyId)), + }); +} + +function conflict( + errorCode: string, + message: string, + details?: Record, +): never { + throw new ActionContractError(message, { errorCode, details }); +} + +function invalidProperty( + definition: Pick, + reason: string, +): never { + throw new ActionContractError( + `Invalid value for property "${definition.name}": ${reason}`, + { + errorCode: "INVALID_PROPERTY_VALUE", + details: { + propertyId: definition.id, + propertyName: definition.name, + propertyType: definition.type, + reason, + }, + statusCode: 400, + }, + ); +} + +function schemaPayload( + database: DatabaseRow, + definitions: DefinitionRow[], + sourceManagedPropertyIds: Set, +) { + return { + naturalKeyPropertyId: database.naturalKeyPropertyId, + properties: definitions + .map((definition) => ({ + id: definition.id, + name: definition.name, + type: definition.type, + description: definition.description, + systemRole: definition.systemRole, + visibility: definition.visibility, + options: parsePropertyOptions(definition.optionsJson), + sourceManaged: sourceManagedPropertyIds.has(definition.id), + })) + .sort((left, right) => left.id.localeCompare(right.id)), + }; +} + +function schemaRevisionFor( + database: DatabaseRow, + definitions: DefinitionRow[], + sourceManagedPropertyIds: Set, +) { + return digest(schemaPayload(database, definitions, sourceManagedPropertyIds)); +} + +function acceptedShape(type: DocumentPropertyType): string { + switch (type) { + case "number": + return "finite number or null"; + case "checkbox": + return "boolean or null"; + case "multi_select": + return "array of option IDs or exact labels, or null"; + case "person": + return "array of person identifiers, or null"; + case "files_media": + return "array of http/https URLs, or null"; + case "date": + return "ISO date/date-time string or { start, end?, includeTime? }, or null"; + case "select": + case "status": + return "option ID, exact option label, or null"; + default: + return "string or null"; + } +} + +async function loadContext( + target: DatabaseMutationTarget, + role: "viewer" | "editor", + db: Db = getDb(), + accessAlreadyResolved = false, +): Promise { + const [database] = await db + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.id, target.databaseId), + isNull(schema.contentDatabases.deletedAt), + ), + ); + if (!database) { + throw new ActionContractError("Content database not found.", { + errorCode: "DATABASE_NOT_FOUND", + statusCode: 404, + }); + } + const databaseDocument = accessAlreadyResolved + ? ( + await db + .select() + .from(schema.documents) + .where( + and( + eq(schema.documents.id, database.documentId), + isNull(schema.documents.trashedAt), + ), + ) + )[0] + : (await assertAccess("document", database.documentId, role)).resource; + if (!databaseDocument) { + throw new ActionContractError("Content database backing page not found.", { + errorCode: "DATABASE_NOT_FOUND", + statusCode: 404, + }); + } + if (!database.spaceId) { + throw new ActionContractError( + "This database does not belong to a Content space.", + { errorCode: "DATABASE_SPACE_REQUIRED", statusCode: 400 }, + ); + } + const authorityScope = database.orgId + ? { kind: "organization" as const, id: database.orgId } + : { kind: "personal" as const, id: database.ownerEmail }; + if ( + target.authorityScope.kind !== authorityScope.kind || + target.authorityScope.id !== authorityScope.id || + database.spaceId !== target.spaceId || + database.documentId !== target.databaseDocumentId || + databaseDocument.spaceId !== target.spaceId || + databaseDocument.id !== target.databaseDocumentId + ) { + conflict("TARGET_MISMATCH", "The Content database target tuple changed.", { + target, + }); + } + if (database.systemRole) { + throw new ActionContractError( + "Reliable row mutations are supported only for ordinary Content databases.", + { errorCode: "SYSTEM_DATABASE_UNSUPPORTED", statusCode: 400 }, + ); + } + const definitions = await db + .select() + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.databaseId, database.id)); + const sourceFields = await db + .select({ propertyId: schema.contentDatabaseSourceFields.propertyId }) + .from(schema.contentDatabaseSourceFields) + .innerJoin( + schema.contentDatabaseSources, + eq( + schema.contentDatabaseSources.id, + schema.contentDatabaseSourceFields.sourceId, + ), + ) + .where(eq(schema.contentDatabaseSources.databaseId, database.id)); + const sourceManagedPropertyIds = new Set( + sourceFields.flatMap((field) => + field.propertyId ? [field.propertyId] : [], + ), + ); + return { + database, + databaseDocument, + definitions, + sourceManagedPropertyIds, + schemaRevision: schemaRevisionFor( + database, + definitions, + sourceManagedPropertyIds, + ), + }; +} + +export async function getDatabaseMutationContract( + target: DatabaseMutationTarget, + options: { accessAlreadyResolved?: boolean } = {}, +): Promise { + const context = await loadContext( + target, + "viewer", + getDb(), + options.accessAlreadyResolved, + ); + return { + target: { + ...target, + }, + schemaRevision: context.schemaRevision, + naturalKeyPropertyId: context.database.naturalKeyPropertyId, + properties: context.definitions + .map((definition) => { + const type = definition.type as DocumentPropertyType; + const sourceManaged = context.sourceManagedPropertyIds.has( + definition.id, + ); + const writable = + !definition.systemRole && + !sourceManaged && + type !== "relation" && + !isComputedPropertyType(type) && + !isBlocksPropertyType(type); + return { + id: definition.id, + name: definition.name, + type, + writable, + sourceManaged, + acceptedShape: writable ? acceptedShape(type) : null, + options: parsePropertyOptions(definition.optionsJson), + }; + }) + .sort((left, right) => left.id.localeCompare(right.id)), + }; +} + +function resolveOption(definition: DefinitionRow, candidate: unknown): string { + if (typeof candidate !== "string") { + invalidProperty(definition, "expected an option ID or exact label"); + } + const options = parsePropertyOptions(definition.optionsJson).options ?? []; + const byId = options.find((option) => option.id === candidate); + if (byId) return byId.id; + const matches = options.filter((option) => option.name === candidate); + if (matches.length !== 1) { + invalidProperty( + definition, + matches.length > 1 ? "option label is ambiguous" : "unknown option", + ); + } + return matches[0]!.id; +} + +function strictDate( + definition: DefinitionRow, + value: unknown, +): DocumentPropertyDateValue { + const date = + typeof value === "string" + ? { start: value } + : value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; + if (!date || typeof date.start !== "string") { + invalidProperty(definition, "expected an ISO date or date range object"); + } + const start = date.start.trim(); + const end = typeof date.end === "string" ? date.end.trim() : undefined; + const includeTime = date.includeTime; + const validIso = (candidate: string) => { + if ( + !/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})?)?$/.test( + candidate, + ) || + Number.isNaN(Date.parse(candidate)) + ) { + return false; + } + const datePart = candidate.slice(0, 10); + return new Date(`${datePart}T00:00:00.000Z`) + .toISOString() + .startsWith(datePart); + }; + if ( + start !== date.start || + (typeof date.end === "string" && end !== date.end) || + !validIso(start) || + (end !== undefined && !validIso(end)) + ) { + invalidProperty(definition, "date is not a valid ISO date/date-time"); + } + if (end && Date.parse(end) < Date.parse(start)) { + invalidProperty(definition, "date range ends before it starts"); + } + if (includeTime !== undefined && typeof includeTime !== "boolean") { + invalidProperty(definition, "includeTime must be boolean"); + } + return { + start, + ...(end ? { end } : {}), + ...(includeTime === undefined ? {} : { includeTime }), + }; +} + +async function strictValue( + definition: DefinitionRow, + value: unknown, +): Promise { + if (value === null) return null; + const type = definition.type as DocumentPropertyType; + switch (type) { + case "number": + if (typeof value !== "number" || !Number.isFinite(value)) + invalidProperty(definition, "expected a finite number"); + return value; + case "checkbox": + if (typeof value !== "boolean") + invalidProperty(definition, "expected a boolean"); + return value; + case "select": + case "status": + return resolveOption(definition, value); + case "multi_select": + if (!Array.isArray(value)) + invalidProperty(definition, "expected an array of options"); + return [ + ...new Set( + value.map((candidate) => resolveOption(definition, candidate)), + ), + ]; + case "date": + return strictDate(definition, value); + case "person": + if ( + !Array.isArray(value) || + value.some( + (entry) => + typeof entry !== "string" || !entry || entry !== entry.trim(), + ) + ) + invalidProperty(definition, "expected an array of person identifiers"); + return [...new Set(value)]; + case "files_media": { + if ( + !Array.isArray(value) || + value.some((entry) => typeof entry !== "string") + ) + invalidProperty(definition, "expected an array of file URLs"); + const urls = value.map((entry) => entry.trim()); + if ( + value.some((entry, index) => entry !== urls[index]) || + urls.some((entry) => { + try { + const url = new URL(entry); + return url.protocol !== "http:" && url.protocol !== "https:"; + } catch { + return true; + } + }) + ) { + invalidProperty(definition, "files must use http or https URLs"); + } + return [...new Set(urls)]; + } + case "url": { + if (typeof value !== "string" || value !== value.trim()) + invalidProperty(definition, "expected a URL"); + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") + throw new Error(); + } catch { + invalidProperty(definition, "expected an http or https URL"); + } + return value; + } + case "email": + if ( + typeof value !== "string" || + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) + ) + invalidProperty(definition, "expected an email address"); + return value; + case "text": + case "place": + case "phone": + if (typeof value !== "string") + invalidProperty(definition, "expected a string"); + return value; + default: + invalidProperty(definition, `property type "${type}" is not writable`); + } +} + +async function normalizePatch( + context: MutationContext, + propertyValues: Record | undefined, +) { + const definitionsById = new Map( + context.definitions.map((definition) => [definition.id, definition]), + ); + const normalized = new Map(); + for (const [propertyId, input] of Object.entries(propertyValues ?? {})) { + const definition = definitionsById.get(propertyId); + if (!definition) { + throw new ActionContractError(`Unknown property "${propertyId}".`, { + errorCode: "UNKNOWN_PROPERTY", + details: { propertyId }, + statusCode: 400, + }); + } + const type = definition.type as DocumentPropertyType; + if ( + definition.systemRole || + context.sourceManagedPropertyIds.has(propertyId) || + type === "relation" || + isComputedPropertyType(type) || + isBlocksPropertyType(type) + ) { + throw new ActionContractError( + `Property "${definition.name}" is not writable by database row mutations.`, + { + errorCode: "PROPERTY_NOT_WRITABLE", + details: { propertyId, propertyType: type }, + statusCode: 400, + }, + ); + } + normalized.set( + propertyId, + serializePropertyValue(await strictValue(definition, input)), + ); + if ( + propertyId === context.database.naturalKeyPropertyId && + (typeof input !== "string" || !input.trim()) + ) { + invalidProperty(definition, "natural key must be a non-empty string"); + } + } + return normalized; +} + +async function ensureNaturalKeyClaim( + db: Db, + context: MutationContext, + args: { + itemId: string; + documentId: string; + values: Map; + now: string; + }, +) { + const propertyId = context.database.naturalKeyPropertyId; + if (!propertyId) return; + const keyValueJson = args.values.get(propertyId); + if (keyValueJson === undefined) return; + const [existing] = await db + .select({ + keyValueJson: schema.contentDatabaseItemKeyClaims.keyValueJson, + }) + .from(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, context.database.id), + eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId), + eq(schema.contentDatabaseItemKeyClaims.documentId, args.documentId), + ), + ) + .limit(1); + if (existing && existing.keyValueJson !== keyValueJson) { + conflict( + "NATURAL_KEY_IMMUTABLE", + "A claimed database natural key cannot be changed. Create a new row instead.", + { propertyId, itemId: args.itemId, documentId: args.documentId }, + ); + } + await db + .insert(schema.contentDatabaseItemKeyClaims) + .values({ + id: nanoid(), + ownerEmail: context.database.ownerEmail, + orgId: context.database.orgId, + databaseId: context.database.id, + propertyId, + keyValueJson, + itemId: args.itemId, + documentId: args.documentId, + createdAt: args.now, + updatedAt: args.now, + }) + .onConflictDoNothing(); + const [claim] = await db + .select({ + itemId: schema.contentDatabaseItemKeyClaims.itemId, + documentId: schema.contentDatabaseItemKeyClaims.documentId, + }) + .from(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, context.database.id), + eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId), + eq(schema.contentDatabaseItemKeyClaims.keyValueJson, keyValueJson), + ), + ); + if ( + !claim || + claim.itemId !== args.itemId || + claim.documentId !== args.documentId + ) { + conflict("NATURAL_KEY_CONFLICT", "The natural key is already in use.", { + propertyId, + }); + } +} + +async function rowSnapshot( + db: Db, + databaseId: string, + itemId: string, + documentId: string, + revisionPropertyIds: Set, +): Promise { + const [row] = await db + .select({ item: schema.contentDatabaseItems, document: schema.documents }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.documents, + eq(schema.documents.id, schema.contentDatabaseItems.documentId), + ) + .where( + and( + eq(schema.contentDatabaseItems.id, itemId), + eq(schema.contentDatabaseItems.databaseId, databaseId), + eq(schema.contentDatabaseItems.documentId, documentId), + isNull(schema.documents.trashedAt), + ), + ); + if (!row) return null; + const values = + revisionPropertyIds.size === 0 + ? [] + : await db + .select() + .from(schema.documentPropertyValues) + .where( + and( + eq(schema.documentPropertyValues.documentId, documentId), + inArray(schema.documentPropertyValues.propertyId, [ + ...revisionPropertyIds, + ]), + ), + ); + const valueMap = new Map( + values.map((value) => [value.propertyId, value.valueJson]), + ); + const revision = databaseRowRevision({ + itemId, + documentId, + title: row.document.title, + values: [...valueMap.entries()].map(([propertyId, valueJson]) => ({ + propertyId, + value: parsePropertyValue(valueJson), + })), + }); + return { ...row, values: valueMap, revision }; +} + +function revisionPropertyIds(context: MutationContext) { + return new Set( + context.definitions + .filter( + (definition) => + !isBlocksPropertyType(definition.type as DocumentPropertyType) && + !isComputedPropertyType(definition.type as DocumentPropertyType), + ) + .map((definition) => definition.id), + ); +} + +function payloadDigest( + operation: DatabaseRowMutationOperation, + input: + | CreateDatabaseRowMutationInput + | UpdateDatabaseRowMutationInput + | UpsertDatabaseRowMutationInput, +) { + return digest({ operation, ...input }); +} + +function resultForReceipt( + operation: DatabaseRowMutationOperation, + outcome: "created" | "updated" | "unchanged", + context: MutationContext, + snapshot: RowSnapshot, + args: { + receiptId: string; + idempotencyKey: string; + payloadDigest: string; + preRowRevision: string | null; + affectedPropertyIds: string[]; + titleAffected: boolean; + idempotencyResult: "applied" | "replayed"; + }, +): ContentDatabaseRowMutationResult { + const target = { + authorityScope: context.database.orgId + ? ({ kind: "organization", id: context.database.orgId } as const) + : ({ kind: "personal", id: context.database.ownerEmail } as const), + spaceId: context.database.spaceId!, + databaseId: context.database.id, + databaseDocumentId: context.database.documentId, + }; + const receipt: ContentDatabaseRowMutationReceipt = { + receiptId: args.receiptId, + operation, + outcome, + target, + schemaRevision: context.schemaRevision, + row: { + itemId: snapshot.item.id, + documentId: snapshot.document.id, + urlPath: `/page/${snapshot.document.id}`, + rowRevision: snapshot.revision, + }, + affected: { + title: args.titleAffected, + propertyIds: args.affectedPropertyIds.sort(), + }, + idempotency: { + key: args.idempotencyKey, + result: args.idempotencyResult, + payloadDigest: args.payloadDigest, + }, + revisions: { + before: args.preRowRevision, + after: snapshot.revision, + }, + readback: { + verified: true, + title: snapshot.document.title, + propertyValues: Object.fromEntries( + [...snapshot.values.entries()].map(([propertyId, valueJson]) => [ + propertyId, + parsePropertyValue(valueJson), + ]), + ), + }, + }; + return { receipt }; +} + +async function replayReceipt( + context: MutationContext, + idempotencyKey: string, + expectedPayloadDigest: string, + db: Db = getDb(), +): Promise { + const [stored] = await db + .select() + .from(schema.contentDatabaseRowMutationReceipts) + .where( + and( + eq( + schema.contentDatabaseRowMutationReceipts.databaseId, + context.database.id, + ), + eq( + schema.contentDatabaseRowMutationReceipts.idempotencyKey, + idempotencyKey, + ), + ), + ); + if (!stored) return null; + if (stored.payloadDigest !== expectedPayloadDigest) { + conflict( + "IDEMPOTENCY_KEY_REUSED", + "This idempotency key was already used for a different row mutation.", + { idempotencyKey }, + ); + } + const snapshot = await rowSnapshot( + db, + stored.databaseId, + stored.itemId, + stored.documentId, + revisionPropertyIds(context), + ); + if (!snapshot || snapshot.revision !== stored.postRowRevision) { + conflict( + "IDEMPOTENCY_REPLAY_DRIFT", + "The committed row changed after this idempotent mutation; replay cannot report the old result as current.", + { idempotencyKey, itemId: stored.itemId, documentId: stored.documentId }, + ); + } + const parsed = JSON.parse( + stored.resultJson, + ) as ContentDatabaseRowMutationResult; + return { + receipt: { + ...parsed.receipt, + idempotency: { ...parsed.receipt.idempotency, result: "replayed" }, + readback: { + ...parsed.receipt.readback, + verified: true, + title: snapshot.document.title, + propertyValues: Object.fromEntries( + [...snapshot.values.entries()].map(([propertyId, valueJson]) => [ + propertyId, + parsePropertyValue(valueJson), + ]), + ), + }, + }, + }; +} + +async function insertReceipt( + db: Db, + context: MutationContext, + operation: DatabaseRowMutationOperation, + input: { idempotencyKey: string }, + inputDigest: string, + result: ContentDatabaseRowMutationResult, +) { + const now = new Date().toISOString(); + const receipt = result.receipt; + await db.insert(schema.contentDatabaseRowMutationReceipts).values({ + id: receipt.receiptId, + ownerEmail: context.database.ownerEmail, + orgId: context.database.orgId, + spaceId: context.database.spaceId!, + databaseId: context.database.id, + databaseDocumentId: context.database.documentId, + operation, + itemId: receipt.row.itemId, + documentId: receipt.row.documentId, + idempotencyKey: input.idempotencyKey, + payloadDigest: inputDigest, + schemaRevision: receipt.schemaRevision, + preRowRevision: receipt.revisions.before, + postRowRevision: receipt.revisions.after, + resultJson: JSON.stringify(result), + createdAt: now, + updatedAt: now, + }); +} + +async function verifyCommittedResult(result: ContentDatabaseRowMutationResult) { + const db = getDb(); + const receipt = result.receipt; + const context = await loadContext(receipt.target, "viewer", db); + const snapshot = await rowSnapshot( + db, + receipt.target.databaseId, + receipt.row.itemId, + receipt.row.documentId, + revisionPropertyIds(context), + ); + if (!snapshot || snapshot.revision !== receipt.row.rowRevision) { + conflict( + "READBACK_MISMATCH", + "The database row committed but its exact read-back could not be verified.", + { receiptId: receipt.receiptId }, + ); + } + return result; +} + +function assertSchema(context: MutationContext, expected: string) { + if (context.schemaRevision !== expected) { + conflict("SCHEMA_REVISION_CONFLICT", "The database schema changed.", { + expected, + actual: context.schemaRevision, + }); + } +} + +async function withMutationLocks( + database: DatabaseRow, + run: () => Promise, +): Promise { + return withPositionLock( + documentsPositionScope(database.ownerEmail, database.documentId), + () => withPositionLock(databaseItemsPositionScope(database.id), run), + ); +} + +async function createInsideTransaction( + tx: Db, + context: MutationContext, + args: { + title?: string; + values: Map; + itemId?: string; + documentId?: string; + }, +) { + const now = new Date().toISOString(); + const documentId = args.documentId ?? nanoid(); + const itemId = args.itemId ?? nanoid(); + const [maxDoc] = await tx + .select({ max: sql`COALESCE(MAX(position), -1)` }) + .from(schema.documents) + .where( + and( + eq(schema.documents.ownerEmail, context.database.ownerEmail), + eq(schema.documents.parentId, context.database.documentId), + ), + ); + const [maxItem] = await tx + .select({ max: sql`COALESCE(MAX(position), -1)` }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, context.database.id)); + const shares = await tx + .select({ + principalType: schema.documentShares.principalType, + principalId: schema.documentShares.principalId, + role: schema.documentShares.role, + }) + .from(schema.documentShares) + .where(eq(schema.documentShares.resourceId, context.database.documentId)); + await tx.insert(schema.documents).values({ + id: documentId, + spaceId: context.database.spaceId, + ownerEmail: context.database.ownerEmail, + orgId: context.database.orgId, + parentId: context.database.documentId, + title: args.title?.trim() ?? "", + content: "", + icon: null, + position: (maxDoc?.max ?? -1) + 1, + isFavorite: 0, + hideFromSearch: context.databaseDocument.hideFromSearch ?? 0, + visibility: context.databaseDocument.visibility ?? "private", + createdAt: now, + updatedAt: now, + }); + await tx.insert(schema.contentDatabaseItems).values({ + id: itemId, + ownerEmail: context.database.ownerEmail, + orgId: context.database.orgId, + databaseId: context.database.id, + documentId, + position: (maxItem?.max ?? -1) + 1, + createdAt: now, + updatedAt: now, + }); + if (args.values.size > 0) { + await tx.insert(schema.documentPropertyValues).values( + [...args.values.entries()].map(([propertyId, valueJson]) => ({ + id: nanoid(), + ownerEmail: context.database.ownerEmail, + documentId, + propertyId, + valueJson, + createdAt: now, + updatedAt: now, + })), + ); + } + if (shares.length > 0) { + await tx.insert(schema.documentShares).values( + shares.map((share) => ({ + id: nanoid(), + resourceId: documentId, + principalType: share.principalType, + principalId: share.principalId, + role: share.role, + createdBy: getRequestUserEmail() ?? context.database.ownerEmail, + createdAt: now, + })), + ); + } + await ensureDocumentFilesMembership(tx, documentId, now); + await ensureNaturalKeyClaim(tx, context, { + itemId, + documentId, + values: args.values, + now, + }); + const snapshot = await rowSnapshot( + tx, + context.database.id, + itemId, + documentId, + revisionPropertyIds(context), + ); + if (!snapshot) + throw new Error("Created row could not be read in its transaction."); + return snapshot; +} + +async function updateInsideTransaction( + tx: Db, + context: MutationContext, + args: { + itemId: string; + documentId: string; + expectedRowRevision: string; + title?: string; + values: Map; + }, +) { + const [lockedDocument] = await tx + .update(schema.documents) + .set({ updatedAt: sql`${schema.documents.updatedAt}` }) + .where( + and( + eq(schema.documents.id, args.documentId), + isNull(schema.documents.trashedAt), + ), + ) + .returning({ id: schema.documents.id }); + if (!lockedDocument) { + throw new ActionContractError("The exact database row was not found.", { + errorCode: "ROW_NOT_FOUND", + statusCode: 404, + }); + } + await tx + .update(schema.contentDatabaseItems) + .set({ updatedAt: sql`${schema.contentDatabaseItems.updatedAt}` }) + .where( + and( + eq(schema.contentDatabaseItems.id, args.itemId), + eq(schema.contentDatabaseItems.databaseId, context.database.id), + eq(schema.contentDatabaseItems.documentId, args.documentId), + ), + ); + const before = await rowSnapshot( + tx, + context.database.id, + args.itemId, + args.documentId, + revisionPropertyIds(context), + ); + if (!before) { + throw new ActionContractError("The exact database row was not found.", { + errorCode: "ROW_NOT_FOUND", + statusCode: 404, + }); + } + if (before.revision !== args.expectedRowRevision) { + conflict("ROW_REVISION_CONFLICT", "The database row changed.", { + expected: args.expectedRowRevision, + actual: before.revision, + itemId: args.itemId, + documentId: args.documentId, + }); + } + const now = new Date().toISOString(); + const changedValues = [...args.values.entries()].filter( + ([propertyId, valueJson]) => before.values.get(propertyId) !== valueJson, + ); + const titleChanged = + args.title !== undefined && args.title.trim() !== before.document.title; + if (titleChanged) { + const nextUpdatedAt = + now > before.document.updatedAt + ? now + : new Date( + new Date(before.document.updatedAt).getTime() + 1, + ).toISOString(); + const [updatedDocument] = await tx + .update(schema.documents) + .set({ title: args.title!.trim(), updatedAt: nextUpdatedAt }) + .where( + and( + eq(schema.documents.id, args.documentId), + eq(schema.documents.updatedAt, before.document.updatedAt), + isNull(schema.documents.trashedAt), + ), + ) + .returning({ id: schema.documents.id }); + if (!updatedDocument) { + conflict("ROW_REVISION_CONFLICT", "The database row changed.", { + expected: args.expectedRowRevision, + itemId: args.itemId, + documentId: args.documentId, + }); + } + } + for (const [propertyId, valueJson] of changedValues) { + const existing = before.values.get(propertyId); + if (existing !== undefined) { + await tx + .update(schema.documentPropertyValues) + .set({ valueJson, updatedAt: now }) + .where( + and( + eq(schema.documentPropertyValues.documentId, args.documentId), + eq(schema.documentPropertyValues.propertyId, propertyId), + ), + ); + } else { + await tx.insert(schema.documentPropertyValues).values({ + id: nanoid(), + ownerEmail: context.database.ownerEmail, + documentId: args.documentId, + propertyId, + valueJson, + createdAt: now, + updatedAt: now, + }); + } + } + await ensureNaturalKeyClaim(tx, context, { + itemId: args.itemId, + documentId: args.documentId, + values: args.values, + now, + }); + const after = await rowSnapshot( + tx, + context.database.id, + args.itemId, + args.documentId, + revisionPropertyIds(context), + ); + if (!after) + throw new Error("Updated row could not be read in its transaction."); + return { + before, + after, + changedPropertyIds: changedValues.map(([propertyId]) => propertyId), + titleChanged, + }; +} + +export async function createDatabaseRow( + input: CreateDatabaseRowMutationInput, +): Promise { + const initial = await loadContext(input.target, "editor"); + const inputDigest = payloadDigest("create", input); + const replay = await replayReceipt( + initial, + input.idempotencyKey, + inputDigest, + ); + if (replay) return replay; + assertSchema(initial, input.expectedSchemaRevision); + const values = await normalizePatch(initial, input.propertyValues); + const result = await withMutationLocks(initial.database, () => + getDb().transaction(async (tx) => { + await lockContentDatabaseMutation( + tx as unknown as Db, + initial.database.id, + ); + const locked = await loadContext( + input.target, + "editor", + tx as unknown as Db, + ); + const lockedReplay = await replayReceipt( + locked, + input.idempotencyKey, + inputDigest, + tx as unknown as Db, + ); + if (lockedReplay) return lockedReplay; + assertSchema(locked, input.expectedSchemaRevision); + await touchContentDatabase( + tx as unknown as Db, + locked.database.id, + new Date().toISOString(), + ); + const snapshot = await createInsideTransaction( + tx as unknown as Db, + locked, + { + title: input.title, + values, + }, + ); + const built = resultForReceipt("create", "created", locked, snapshot, { + receiptId: nanoid(), + idempotencyKey: input.idempotencyKey, + payloadDigest: inputDigest, + preRowRevision: null, + affectedPropertyIds: [...values.keys()], + titleAffected: input.title !== undefined, + idempotencyResult: "applied", + }); + await insertReceipt( + tx as unknown as Db, + locked, + "create", + input, + inputDigest, + built, + ); + return built; + }), + ); + return verifyCommittedResult(result); +} + +export async function updateDatabaseRow( + input: UpdateDatabaseRowMutationInput, +): Promise { + const initial = await loadContext(input.target, "editor"); + await assertAccess("document", input.documentId, "editor"); + const inputDigest = payloadDigest("update", input); + const replay = await replayReceipt( + initial, + input.idempotencyKey, + inputDigest, + ); + if (replay) return replay; + assertSchema(initial, input.expectedSchemaRevision); + const values = await normalizePatch(initial, input.propertyValues); + const result = await withMutationLocks(initial.database, () => + getDb().transaction(async (tx) => { + await lockContentDatabaseMutation( + tx as unknown as Db, + initial.database.id, + ); + const locked = await loadContext( + input.target, + "editor", + tx as unknown as Db, + ); + const lockedReplay = await replayReceipt( + locked, + input.idempotencyKey, + inputDigest, + tx as unknown as Db, + ); + if (lockedReplay) return lockedReplay; + assertSchema(locked, input.expectedSchemaRevision); + const updated = await updateInsideTransaction( + tx as unknown as Db, + locked, + { + itemId: input.itemId, + documentId: input.documentId, + expectedRowRevision: input.expectedRowRevision, + title: input.title, + values, + }, + ); + await touchContentDatabase( + tx as unknown as Db, + locked.database.id, + new Date().toISOString(), + ); + const built = resultForReceipt( + "update", + updated.titleChanged || updated.changedPropertyIds.length > 0 + ? "updated" + : "unchanged", + locked, + updated.after, + { + receiptId: nanoid(), + idempotencyKey: input.idempotencyKey, + payloadDigest: inputDigest, + preRowRevision: updated.before.revision, + affectedPropertyIds: updated.changedPropertyIds, + titleAffected: updated.titleChanged, + idempotencyResult: "applied", + }, + ); + await insertReceipt( + tx as unknown as Db, + locked, + "update", + input, + inputDigest, + built, + ); + return built; + }), + ); + return verifyCommittedResult(result); +} + +export async function upsertDatabaseRow( + input: UpsertDatabaseRowMutationInput, +): Promise { + const initial = await loadContext(input.target, "editor"); + const inputDigest = payloadDigest("upsert", input); + const replay = await replayReceipt( + initial, + input.idempotencyKey, + inputDigest, + ); + if (replay) return replay; + assertSchema(initial, input.expectedSchemaRevision); + const keyPropertyId = initial.database.naturalKeyPropertyId; + if (!keyPropertyId) { + throw new ActionContractError( + "This database has no configured natural key.", + { + errorCode: "NATURAL_KEY_NOT_CONFIGURED", + statusCode: 400, + }, + ); + } + const keyDefinition = initial.definitions.find( + (definition) => definition.id === keyPropertyId, + ); + if (!keyDefinition || keyDefinition.type !== "text") { + conflict( + "NATURAL_KEY_INVALID", + "The configured natural key is missing or no longer a text property.", + { keyPropertyId }, + ); + } + const values = await normalizePatch(initial, { + ...(input.propertyValues ?? {}), + [keyPropertyId]: input.keyValue, + }); + const keyValueJson = values.get(keyPropertyId)!; + const [initialClaim] = await getDb() + .select({ + itemId: schema.contentDatabaseItemKeyClaims.itemId, + documentId: schema.contentDatabaseItemKeyClaims.documentId, + }) + .from(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, initial.database.id), + eq(schema.contentDatabaseItemKeyClaims.propertyId, keyPropertyId), + eq(schema.contentDatabaseItemKeyClaims.keyValueJson, keyValueJson), + ), + ); + if (initialClaim) { + await assertAccess("document", initialClaim.documentId, "editor"); + } + const result = await withMutationLocks(initial.database, () => + getDb().transaction(async (tx) => { + await lockContentDatabaseMutation( + tx as unknown as Db, + initial.database.id, + ); + const locked = await loadContext( + input.target, + "editor", + tx as unknown as Db, + ); + const lockedReplay = await replayReceipt( + locked, + input.idempotencyKey, + inputDigest, + tx as unknown as Db, + ); + if (lockedReplay) return lockedReplay; + assertSchema(locked, input.expectedSchemaRevision); + if (locked.database.naturalKeyPropertyId !== keyPropertyId) { + conflict("SCHEMA_REVISION_CONFLICT", "The natural key changed."); + } + const [claim] = await tx + .select() + .from(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq( + schema.contentDatabaseItemKeyClaims.databaseId, + locked.database.id, + ), + eq(schema.contentDatabaseItemKeyClaims.propertyId, keyPropertyId), + eq(schema.contentDatabaseItemKeyClaims.keyValueJson, keyValueJson), + ), + ); + if (!claim && input.expectedRowRevision !== null) { + conflict("ROW_NOT_FOUND", "No row exists for this natural key.", { + keyPropertyId, + keyValue: input.keyValue, + }); + } + if (claim && input.expectedRowRevision === null) { + conflict( + "ROW_ALREADY_EXISTS", + "A row already exists for this natural key.", + ); + } + if ( + claim && + (!initialClaim || + initialClaim.itemId !== claim.itemId || + initialClaim.documentId !== claim.documentId) + ) { + conflict( + "ROW_REVISION_CONFLICT", + "The natural-key row changed while the mutation was starting.", + ); + } + if (!claim) { + const itemId = nanoid(); + const documentId = nanoid(); + const now = new Date().toISOString(); + await tx.insert(schema.contentDatabaseItemKeyClaims).values({ + id: nanoid(), + ownerEmail: locked.database.ownerEmail, + orgId: locked.database.orgId, + databaseId: locked.database.id, + propertyId: keyPropertyId, + keyValueJson, + itemId, + documentId, + createdAt: now, + updatedAt: now, + }); + const snapshot = await createInsideTransaction( + tx as unknown as Db, + locked, + { + title: input.title, + values, + itemId, + documentId, + }, + ); + await touchContentDatabase( + tx as unknown as Db, + locked.database.id, + now, + ); + const built = resultForReceipt("upsert", "created", locked, snapshot, { + receiptId: nanoid(), + idempotencyKey: input.idempotencyKey, + payloadDigest: inputDigest, + preRowRevision: null, + affectedPropertyIds: [...values.keys()], + titleAffected: input.title !== undefined, + idempotencyResult: "applied", + }); + await insertReceipt( + tx as unknown as Db, + locked, + "upsert", + input, + inputDigest, + built, + ); + return built; + } + const updated = await updateInsideTransaction( + tx as unknown as Db, + locked, + { + itemId: claim.itemId, + documentId: claim.documentId, + expectedRowRevision: input.expectedRowRevision!, + title: input.title, + values, + }, + ); + await touchContentDatabase( + tx as unknown as Db, + locked.database.id, + new Date().toISOString(), + ); + const built = resultForReceipt( + "upsert", + updated.titleChanged || updated.changedPropertyIds.length > 0 + ? "updated" + : "unchanged", + locked, + updated.after, + { + receiptId: nanoid(), + idempotencyKey: input.idempotencyKey, + payloadDigest: inputDigest, + preRowRevision: updated.before.revision, + affectedPropertyIds: updated.changedPropertyIds, + titleAffected: updated.titleChanged, + idempotencyResult: "applied", + }, + ); + await insertReceipt( + tx as unknown as Db, + locked, + "upsert", + input, + inputDigest, + built, + ); + return built; + }), + ); + return verifyCommittedResult(result); +} diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index 904b66fce7..66335cf775 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -44,6 +44,10 @@ import { normalizeContentSpaceEmail, resolveContentSpaceAccess, } from "./_content-space-access.js"; +import { + databaseRowRevision, + getDatabaseMutationContract, +} from "./_database-row-mutation.js"; import { getAllContentDatabaseSourceSnapshots } from "./_database-source-utils.js"; import { applyFederatedOverlayValues, @@ -1039,6 +1043,21 @@ export async function getContentDatabasePageResponse( queued: bodyHydrationQueued, }), properties: propertiesByDocumentId.get(document.id) ?? [], + rowRevision: databaseRowRevision({ + itemId: item.id, + documentId: document.id, + title: document.title, + values: (propertiesByDocumentId.get(document.id) ?? []) + .filter( + (property) => + !isBlocksPropertyType(property.definition.type) && + !isComputedPropertyType(property.definition.type), + ) + .map((property) => ({ + propertyId: property.definition.id, + value: property.value, + })), + }), }); } @@ -1184,6 +1203,26 @@ export async function getContentDatabaseResponse( sources: page.sources, pagination: page.pagination, tableQueryMode: page.tableQueryMode, + mutationContract: + page.databaseRecord.spaceId && !page.databaseRecord.systemRole + ? await getDatabaseMutationContract( + { + authorityScope: page.databaseRecord.orgId + ? { + kind: "organization", + id: page.databaseRecord.orgId, + } + : { + kind: "personal", + id: page.databaseRecord.ownerEmail, + }, + spaceId: page.databaseRecord.spaceId, + databaseId: page.databaseRecord.id, + databaseDocumentId: page.databaseRecord.documentId, + }, + { accessAlreadyResolved: true }, + ) + : undefined, }; } @@ -1453,6 +1492,11 @@ export async function deleteDatabaseDataForDocument( .where( eq(schema.contentDatabaseMigrationReceipts.databaseId, database.id), ); + await db + .delete(schema.contentDatabaseRowMutationReceipts) + .where( + eq(schema.contentDatabaseRowMutationReceipts.databaseId, database.id), + ); await db .delete(schema.documentPropertyDefinitions) .where(eq(schema.documentPropertyDefinitions.databaseId, database.id)); diff --git a/templates/content/actions/_property-utils.ts b/templates/content/actions/_property-utils.ts index 6b45736dca..bc37b2077b 100644 --- a/templates/content/actions/_property-utils.ts +++ b/templates/content/actions/_property-utils.ts @@ -58,7 +58,8 @@ type ContentDatabaseSummaryRow = Pick< | "viewConfigJson" | "createdAt" | "updatedAt" ->; +> & + Partial>; type ContentDatabaseItemRow = InferSelectModel< typeof schema.contentDatabaseItems >; @@ -192,8 +193,10 @@ export function serializeDatabase( return { id: database.id, documentId: database.documentId, + spaceId: database.spaceId, title: database.title, systemRole: database.systemRole, + naturalKeyPropertyId: database.naturalKeyPropertyId, description, viewConfig: parseDatabaseViewConfig(database.viewConfigJson), createdAt: database.createdAt, diff --git a/templates/content/actions/add-database-item.ts b/templates/content/actions/add-database-item.ts index 363d6989b1..1afeaa2444 100644 --- a/templates/content/actions/add-database-item.ts +++ b/templates/content/actions/add-database-item.ts @@ -1,255 +1,77 @@ import { defineAction } from "@agent-native/core"; -import { writeAppState } from "@agent-native/core/application-state"; -import { getRequestUserEmail } from "@agent-native/core/server/request-context"; -import { assertAccess } from "@agent-native/core/sharing"; -import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { buildDeepLink } from "@agent-native/core/server"; import { z } from "zod"; -import { getDb, schema } from "../server/db/index.js"; +import type { ContentDatabaseRowMutationResult } from "../shared/api.js"; import { - isComputedPropertyType, - type DocumentPropertyType, -} from "../shared/properties.js"; -import { - lockContentDatabaseMutation, - touchContentDatabase, -} from "./_content-database-mutation-lock.js"; -import { ensureDocumentFilesMembership } from "./_content-files.js"; + createDatabaseRow, + databaseMutationEnvelopeSchema, +} from "./_database-row-mutation.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; -import { - databaseItemsPositionScope, - documentsPositionScope, - withPositionLock, -} from "./_position-utils.js"; -import { nanoid, normalizedValueJson } from "./_property-utils.js"; -export default defineAction({ - description: "Add a page item to a content database table.", - schema: z.object({ - databaseId: z.string().describe("Database ID"), - title: z.string().optional().describe("New row page title"), - propertyValues: z - .record(z.string(), z.unknown()) - .optional() - .describe("Initial property values keyed by property definition ID"), - }), - run: async ({ databaseId, title, propertyValues }) => { - const db = getDb(); - const [database] = await db - .select() - .from(schema.contentDatabases) - .where( - and( - eq(schema.contentDatabases.id, databaseId), - isNull(schema.contentDatabases.deletedAt), - ), - ); - if (!database) throw new Error(`Database "${databaseId}" not found`); - if (database.systemRole === "workspaces") { - throw new Error("Use create-content-space to add a workspace"); - } +const schema = databaseMutationEnvelopeSchema.extend({ + title: z + .string() + .trim() + .min(1) + .max(500) + .optional() + .describe("New row page title"), + propertyValues: z + .record(z.string(), z.unknown()) + .optional() + .describe("Strict property values keyed by property definition ID"), +}); - const access = await assertAccess( - "document", - database.documentId, - "editor", +export default defineAction({ + description: + "Create one row in an exact ordinary Content database using its discovered schema revision. Strictly validates every non-Blocks property, applies the side effect once per idempotency key, and returns a verified receipt with stable row identity.", + schema, + audit: { + recordInputs: false, + target: (args) => ({ + type: "content-database", + id: args.target.databaseId, + visibility: "private", + }), + summary: (_args, result) => { + const receipt = (result as ContentDatabaseRowMutationResult | null) + ?.receipt; + return receipt + ? `Created Content database row ${receipt.row.itemId}` + : "Created Content database row"; + }, + }, + run: async (args): Promise => { + const result = await createDatabaseRow(args); + const response = await getContentDatabaseResponse( + result.receipt.target.databaseId, + { + limit: 1, + offset: 0, + documentIds: [result.receipt.row.documentId], + }, ); - const databaseDocument = access.resource; - if ( - database.spaceId && - databaseDocument.spaceId && - databaseDocument.spaceId !== database.spaceId - ) { + const createdItem = response.items[0]; + if (!createdItem || createdItem.id !== result.receipt.row.itemId) { throw new Error( - `Database "${databaseId}" has inconsistent Content space`, + "Created row receipt did not resolve to its exact read-back.", ); } - const now = new Date().toISOString(); - const databaseSpaceId = - database.spaceId ?? (databaseDocument.spaceId as string | null); - if (!databaseSpaceId) { - throw new Error("Database does not belong to a Content space."); - } - if (databaseSpaceId && (!database.spaceId || !databaseDocument.spaceId)) { - await db.transaction(async (tx) => { - if (!database.spaceId) { - await tx - .update(schema.contentDatabases) - .set({ spaceId: databaseSpaceId, updatedAt: now }) - .where(eq(schema.contentDatabases.id, databaseId)); - } - if (!databaseDocument.spaceId) { - await tx - .update(schema.documents) - .set({ spaceId: databaseSpaceId, updatedAt: now }) - .where(eq(schema.documents.id, database.documentId)); - } - await ensureDocumentFilesMembership(tx, database.documentId, now); - }); - } - - const documentId = nanoid(); - const itemId = nanoid(); - - const inheritedShares = await db - .select({ - principalType: schema.documentShares.principalType, - principalId: schema.documentShares.principalId, - role: schema.documentShares.role, - }) - .from(schema.documentShares) - .where(eq(schema.documentShares.resourceId, database.documentId)); - - const initialValues = Object.entries(propertyValues ?? {}); - - await withPositionLock( - documentsPositionScope(database.ownerEmail, database.documentId), - () => - withPositionLock(databaseItemsPositionScope(databaseId), async () => { - await db.transaction(async (tx) => { - await lockContentDatabaseMutation( - tx as unknown as ReturnType, - databaseId, - ); - await touchContentDatabase( - tx as unknown as ReturnType, - databaseId, - now, - ); - const propertyValueRows: Array< - typeof schema.documentPropertyValues.$inferInsert - > = []; - if (initialValues.length > 0) { - const requestedPropertyIds = initialValues.map( - ([propertyId]) => propertyId, - ); - const definitions = await tx - .select() - .from(schema.documentPropertyDefinitions) - .where( - and( - eq( - schema.documentPropertyDefinitions.ownerEmail, - database.ownerEmail, - ), - eq( - schema.documentPropertyDefinitions.databaseId, - databaseId, - ), - inArray( - schema.documentPropertyDefinitions.id, - requestedPropertyIds, - ), - ), - ); - const definitionById = new Map( - definitions.map((definition) => [definition.id, definition]), - ); - for (const [propertyId, value] of initialValues) { - const definition = definitionById.get(propertyId); - const type = definition?.type as - | DocumentPropertyType - | undefined; - if (!definition || !type || isComputedPropertyType(type)) - continue; - propertyValueRows.push({ - id: nanoid(), - ownerEmail: database.ownerEmail, - documentId, - propertyId, - valueJson: normalizedValueJson(type, value), - createdAt: now, - updatedAt: now, - }); - } - } - const [maxDocPos] = await tx - .select({ max: sql`COALESCE(MAX(position), -1)` }) - .from(schema.documents) - .where( - and( - eq(schema.documents.ownerEmail, database.ownerEmail), - eq(schema.documents.parentId, database.documentId), - ), - ); - const [maxItemPos] = await tx - .select({ max: sql`COALESCE(MAX(position), -1)` }) - .from(schema.contentDatabaseItems) - .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); - - await tx.insert(schema.documents).values({ - id: documentId, - spaceId: databaseSpaceId, - ownerEmail: database.ownerEmail, - orgId: database.orgId, - parentId: database.documentId, - title: title?.trim() ?? "", - content: "", - icon: null, - position: (maxDocPos?.max ?? -1) + 1, - isFavorite: 0, - hideFromSearch: databaseDocument.hideFromSearch ?? 0, - visibility: databaseDocument.visibility ?? "private", - createdAt: now, - updatedAt: now, - }); - await tx.insert(schema.contentDatabaseItems).values({ - id: itemId, - ownerEmail: database.ownerEmail, - orgId: database.orgId, - databaseId, - documentId, - position: (maxItemPos?.max ?? -1) + 1, - createdAt: now, - updatedAt: now, - }); - if (inheritedShares.length > 0) { - await tx.insert(schema.documentShares).values( - inheritedShares.map((share) => ({ - id: nanoid(), - resourceId: documentId, - principalType: share.principalType, - principalId: share.principalId, - role: share.role, - createdBy: getRequestUserEmail() ?? database.ownerEmail, - createdAt: now, - })), - ); - } - if (propertyValueRows.length > 0) { - await tx - .insert(schema.documentPropertyValues) - .values(propertyValueRows); - } - await ensureDocumentFilesMembership(tx, documentId, now); - }); - }), - ); - - await writeAppState("refresh-signal", { ts: Date.now() }).catch(() => { - // The row is already committed; polling will reconcile if a concurrent - // SQLite writer briefly blocks this best-effort refresh hint. - }); - - const response = await getContentDatabaseResponse(databaseId, { - limit: 100, - offset: 0, - }); - const createdItem = - response.items.find((item) => item.id === itemId) ?? - ( - await getContentDatabaseResponse(databaseId, { - limit: 1, - offset: 0, - documentIds: [documentId], - }) - ).items.find((item) => item.id === itemId); + return { ...result, createdItem }; + }, + link: ({ result }) => { + const documentId = (result as ContentDatabaseRowMutationResult | null) + ?.receipt.row.documentId; + if (!documentId) return null; return { - ...response, - createdItem, - createdItemId: itemId, - createdDocumentId: documentId, - createdDocumentUpdatedAt: now, + url: buildDeepLink({ + app: "content", + view: "editor", + params: { documentId }, + }), + label: "Open database row", + view: "editor", }; }, }); diff --git a/templates/content/actions/blocks-seeding.db.test.ts b/templates/content/actions/blocks-seeding.db.test.ts index 3690bbf606..dd4283303a 100644 --- a/templates/content/actions/blocks-seeding.db.test.ts +++ b/templates/content/actions/blocks-seeding.db.test.ts @@ -255,8 +255,15 @@ describe("seedDefaultBlocksField — single-primary invariant (findings 1, 2)", ], }, }); - const row = await addDatabaseItemAction.run({ + const mutationRead = await getContentDatabaseAction.run({ databaseId: database.database.id, + }); + if (!("database" in mutationRead) || !mutationRead.mutationContract) + throw new Error("Fixture database has no mutation contract."); + const row = await addDatabaseItemAction.run({ + target: mutationRead.mutationContract.target, + expectedSchemaRevision: mutationRead.mutationContract.schemaRevision, + idempotencyKey: `blocks-seeding-${suffix}`, title: `Row ${suffix}`, }); const page = await getDocumentAction.run({ id: rootId }); @@ -269,7 +276,7 @@ describe("seedDefaultBlocksField — single-primary invariant (findings 1, 2)", const databaseHelperRead = await databaseUtils.getContentDatabaseResponse(database.database.id); const rowPage = await getDocumentAction.run({ - id: row.createdDocumentId, + id: row.receipt.row.documentId, }); return { page, diff --git a/templates/content/actions/configure-document-property.ts b/templates/content/actions/configure-document-property.ts index 3db35a7c95..0afaf68e9a 100644 --- a/templates/content/actions/configure-document-property.ts +++ b/templates/content/actions/configure-document-property.ts @@ -51,6 +51,12 @@ export default defineAction({ "Stable guidance describing what this property means and which value belongs here", ), type: z.enum(CREATABLE_DOCUMENT_PROPERTY_TYPES).describe("Property type"), + naturalKey: z + .boolean() + .optional() + .describe( + "Declare or clear this ordinary text property as the database's single natural key", + ), visibility: z .enum(DOCUMENT_PROPERTY_VISIBILITIES) .optional() @@ -103,6 +109,7 @@ export default defineAction({ const now = new Date().toISOString(); const name = args.name.trim(); const type = args.type as DocumentPropertyType; + const propertyId = args.id ?? nanoid(); const optionsJson = optionsForNewProperty(type, args.options as any); const database = await resolvePropertyDatabaseForDocument( document, @@ -114,6 +121,14 @@ export default defineAction({ "Properties belong to databases. Create or open a database before adding properties.", ); } + if (args.naturalKey === true && type !== "text") { + throw new Error( + "A database natural key must be an ordinary text property.", + ); + } + if (args.naturalKey !== undefined && database.systemRole) { + throw new Error("System databases cannot configure a natural key."); + } if (args.id) { const [existing] = await db @@ -135,6 +150,11 @@ export default defineAction({ tx as unknown as ReturnType, database.id, ); + const [lockedDatabase] = await tx + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, database.id)); + if (!lockedDatabase) throw new Error("Database not found."); let [lockedDefinition] = await tx .select() .from(schema.documentPropertyDefinitions) @@ -150,6 +170,14 @@ export default defineAction({ ); if (!lockedDefinition) throw new Error(`Property "${args.id}" not found`); + if ( + lockedDatabase.naturalKeyPropertyId === args.id && + type !== "text" + ) { + throw new Error( + "Clear the database natural key before changing this property's type.", + ); + } if (lockedDefinition.systemRole) { throw new Error("System properties cannot be changed."); } @@ -260,6 +288,13 @@ export default defineAction({ updatedAt: now, }) .where(eq(schema.documentPropertyDefinitions.id, args.id!)); + await configureNaturalKey(tx, { + database: lockedDatabase, + propertyId, + naturalKey: args.naturalKey, + ownerEmail: document.ownerEmail, + now, + }); }); } else { await withPositionLock( @@ -270,6 +305,11 @@ export default defineAction({ tx as unknown as ReturnType, database.id, ); + const [lockedDatabase] = await tx + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, database.id)); + if (!lockedDatabase) throw new Error("Database not found."); const [maxPos] = await tx .select({ max: sql`COALESCE(MAX(position), -1)`, @@ -289,7 +329,7 @@ export default defineAction({ ); await tx.insert(schema.documentPropertyDefinitions).values({ - id: nanoid(), + id: propertyId, ownerEmail: document.ownerEmail, orgId: document.orgId ?? null, databaseId: database.id, @@ -302,6 +342,13 @@ export default defineAction({ createdAt: now, updatedAt: now, }); + await configureNaturalKey(tx, { + database: lockedDatabase, + propertyId, + naturalKey: args.naturalKey, + ownerEmail: document.ownerEmail, + now, + }); }); }, ); @@ -316,3 +363,117 @@ export default defineAction({ }; }, }); + +async function configureNaturalKey( + tx: Parameters["transaction"]>[0]>[0], + args: { + database: typeof schema.contentDatabases.$inferSelect; + propertyId: string; + naturalKey: boolean | undefined; + ownerEmail: string; + now: string; + }, +) { + if (args.naturalKey === undefined) return; + if (!args.naturalKey) { + if (args.database.naturalKeyPropertyId === args.propertyId) { + await tx + .delete(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq( + schema.contentDatabaseItemKeyClaims.databaseId, + args.database.id, + ), + eq(schema.contentDatabaseItemKeyClaims.propertyId, args.propertyId), + ), + ); + await tx + .update(schema.contentDatabases) + .set({ naturalKeyPropertyId: null, updatedAt: args.now }) + .where(eq(schema.contentDatabases.id, args.database.id)); + } + return; + } + if ( + args.database.naturalKeyPropertyId && + args.database.naturalKeyPropertyId !== args.propertyId + ) { + throw new Error( + "Clear the existing database natural key before configuring another one.", + ); + } + const [sourceField] = await tx + .select({ id: schema.contentDatabaseSourceFields.id }) + .from(schema.contentDatabaseSourceFields) + .where(eq(schema.contentDatabaseSourceFields.propertyId, args.propertyId)) + .limit(1); + if (sourceField) { + throw new Error("A source-managed property cannot be a natural key."); + } + const values = await tx + .select({ + valueJson: schema.documentPropertyValues.valueJson, + itemId: schema.contentDatabaseItems.id, + documentId: schema.contentDatabaseItems.documentId, + }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.documentPropertyValues, + and( + eq( + schema.documentPropertyValues.documentId, + schema.contentDatabaseItems.documentId, + ), + eq(schema.documentPropertyValues.propertyId, args.propertyId), + ), + ) + .where(eq(schema.contentDatabaseItems.databaseId, args.database.id)); + const claims = new Map(); + for (const value of values) { + let parsed: unknown; + try { + parsed = JSON.parse(value.valueJson); + } catch { + throw new Error("Natural key values must be readable strings."); + } + if (parsed === null || parsed === "") continue; + if (typeof parsed !== "string") { + throw new Error("Natural key values must be non-empty strings."); + } + if (claims.has(value.valueJson)) { + throw new Error( + `Natural key value ${value.valueJson} belongs to more than one row.`, + ); + } + claims.set(value.valueJson, value); + } + await tx + .delete(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, args.database.id), + eq(schema.contentDatabaseItemKeyClaims.propertyId, args.propertyId), + ), + ); + if (claims.size > 0) { + await tx.insert(schema.contentDatabaseItemKeyClaims).values( + [...claims.entries()].map(([keyValueJson, value]) => ({ + id: nanoid(), + ownerEmail: args.ownerEmail, + orgId: args.database.orgId, + databaseId: args.database.id, + propertyId: args.propertyId, + keyValueJson, + itemId: value.itemId, + documentId: value.documentId, + createdAt: args.now, + updatedAt: args.now, + })), + ); + } + await tx + .update(schema.contentDatabases) + .set({ naturalKeyPropertyId: args.propertyId, updatedAt: args.now }) + .where(eq(schema.contentDatabases.id, args.database.id)); +} diff --git a/templates/content/actions/content-database-lifecycle.db.test.ts b/templates/content/actions/content-database-lifecycle.db.test.ts index 8eccd6044f..ef148a9803 100644 --- a/templates/content/actions/content-database-lifecycle.db.test.ts +++ b/templates/content/actions/content-database-lifecycle.db.test.ts @@ -1665,15 +1665,25 @@ describe("content database soft-delete actions and reads", () => { }); it("blocks row mutations for soft-deleted databases", async () => { - const { databaseId } = await createDatabase({ + const { databaseId, databaseDocumentId } = await createDatabase({ deletedAt: new Date().toISOString(), }); await expect( runWithRequestContext({ userEmail: OWNER }, () => - addDatabaseItemAction.run({ databaseId, title: "Should not write" }), + addDatabaseItemAction.run({ + target: { + authorityScope: { kind: "personal", id: OWNER }, + spaceId: "fixture-space", + databaseId, + databaseDocumentId, + }, + expectedSchemaRevision: "sha256:fixture", + idempotencyKey: "soft-deleted-database", + title: "Should not write", + }), ), - ).rejects.toThrow(`Database "${databaseId}" not found`); + ).rejects.toThrow("Content database not found"); const db = getDb(); const rows = await db diff --git a/templates/content/actions/content-spaces.db.test.ts b/templates/content/actions/content-spaces.db.test.ts index ad4eae1af5..d9df2ba0c7 100644 --- a/templates/content/actions/content-spaces.db.test.ts +++ b/templates/content/actions/content-spaces.db.test.ts @@ -477,10 +477,19 @@ describe("Content space provisioning", () => { ).rejects.toThrow("System Content database documents cannot be deleted"); await expect( addDatabaseItemAction.run({ - databaseId: workspaces.id, + target: { + authorityScope: { kind: "personal", id: OWNER }, + spaceId: workspaces.spaceId!, + databaseId: workspaces.id, + databaseDocumentId: workspaces.documentId, + }, + expectedSchemaRevision: "sha256:system-database", + idempotencyKey: "reject-system-workspace-create", title: "Not a workspace", }), - ).rejects.toThrow("Use create-content-space to add a workspace"); + ).rejects.toThrow( + "Reliable row mutations are supported only for ordinary Content databases", + ); }); }); diff --git a/templates/content/actions/database-row-batch-actions.db.test.ts b/templates/content/actions/database-row-batch-actions.db.test.ts index bb266eb3e9..09cf758b8f 100644 --- a/templates/content/actions/database-row-batch-actions.db.test.ts +++ b/templates/content/actions/database-row-batch-actions.db.test.ts @@ -186,6 +186,26 @@ async function orderedRows(databaseId: string) { .orderBy(asc(schema.contentDatabaseItems.position)); } +async function createRowThroughMutationContract( + databaseId: string, + title: string, + idempotencyKey: string, +) { + const discovered = await runWithRequestContext({ userEmail: OWNER }, () => + getContentDatabaseAction.run({ databaseId }), + ); + if (!("database" in discovered) || !discovered.mutationContract) + throw new Error("Fixture database has no mutation contract."); + return runWithRequestContext({ userEmail: OWNER }, () => + addDatabaseItemAction.run({ + target: discovered.mutationContract!.target, + expectedSchemaRevision: discovered.mutationContract!.schemaRevision, + idempotencyKey, + title, + }), + ); +} + describe("database row batch actions", () => { it("reports truthful page-view capability for database rows", async () => { const { databaseId, databaseDocumentId, rows } = @@ -688,31 +708,42 @@ describe("database row batch actions", () => { }); it("rejects removal from system databases whose memberships are canonical", async () => { - const [filesDatabase] = await getDb() - .select({ id: schema.contentDatabases.id }) + const db = getDb(); + const [filesDatabase] = await db + .select({ + id: schema.contentDatabases.id, + documentId: schema.contentDatabases.documentId, + }) .from(schema.contentDatabases) .where(eq(schema.contentDatabases.systemRole, "files")); - const created = await runWithRequestContext({ userEmail: OWNER }, () => - addDatabaseItemAction.run({ - databaseId: filesDatabase.id, - title: "Canonical file", - }), - ); + const documentId = await createDocument({ + parentId: filesDatabase.documentId, + title: "Canonical file", + }); + const itemId = nextId("files_item"); + const now = new Date().toISOString(); + await db.insert(schema.contentDatabaseItems).values({ + id: itemId, + ownerEmail: OWNER, + databaseId: filesDatabase.id, + documentId, + position: 0, + createdAt: now, + updatedAt: now, + }); await expect( runWithRequestContext({ userEmail: OWNER }, () => removeDatabaseItemsAction.run({ databaseId: filesDatabase.id, - itemIds: [created.createdItemId], + itemIds: [itemId], }), ), ).rejects.toThrow( "System database memberships cannot be removed from this surface.", ); expect(await orderedRows(filesDatabase.id)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ itemId: created.createdItemId }), - ]), + expect.arrayContaining([expect.objectContaining({ itemId })]), ); }); @@ -1280,27 +1311,30 @@ describe("database row batch actions", () => { const results = await Promise.all( Array.from({ length: concurrentAdds }, (_, index) => runWithRequestContext({ userEmail: OWNER }, () => - addDatabaseItemAction.run({ + createRowThroughMutationContract( databaseId, - title: `Concurrent ${index}`, - }), + `Concurrent ${index}`, + `concurrent-add-${index}`, + ), ), ), ); expect(results).toHaveLength(concurrentAdds); - const createdItemIds = results.map((result) => result.createdItemId); + const createdItemIds = results.map((result) => result.receipt.row.itemId); expect(new Set(createdItemIds).size).toBe(concurrentAdds); for (const result of results) { expect(result.createdItem).toMatchObject({ - id: result.createdItemId, - document: { id: result.createdDocumentId }, + id: result.receipt.row.itemId, + document: { id: result.receipt.row.documentId }, }); const [createdDocument] = await getDb() .select({ updatedAt: schema.documents.updatedAt }) .from(schema.documents) - .where(eq(schema.documents.id, result.createdDocumentId)); - expect(result.createdDocumentUpdatedAt).toBe(createdDocument.updatedAt); + .where(eq(schema.documents.id, result.receipt.row.documentId)); + expect(result.createdItem.document.updatedAt).toBe( + createdDocument.updatedAt, + ); } const rows = await orderedRows(databaseId); diff --git a/templates/content/actions/delete-document-property.ts b/templates/content/actions/delete-document-property.ts index 9330e9c225..69fb66850a 100644 --- a/templates/content/actions/delete-document-property.ts +++ b/templates/content/actions/delete-document-property.ts @@ -116,6 +116,16 @@ export default defineAction({ .delete(schema.documentPropertyDefinitions) .where(eq(schema.documentPropertyDefinitions.id, propertyId)); + if (database.naturalKeyPropertyId === propertyId) { + await tx + .update(schema.contentDatabases) + .set({ + naturalKeyPropertyId: null, + updatedAt: new Date().toISOString(), + }) + .where(eq(schema.contentDatabases.id, database.id)); + } + if (isBlocks) { await tx .delete(schema.documentBlockFieldContents) diff --git a/templates/content/actions/set-document-property.ts b/templates/content/actions/set-document-property.ts index cafd59097f..0bccca0fe2 100644 --- a/templates/content/actions/set-document-property.ts +++ b/templates/content/actions/set-document-property.ts @@ -166,7 +166,10 @@ export default defineAction({ database.id, ); const [lockedDatabase] = await tx - .select({ id: schema.contentDatabases.id }) + .select({ + id: schema.contentDatabases.id, + naturalKeyPropertyId: schema.contentDatabases.naturalKeyPropertyId, + }) .from(schema.contentDatabases) .where( and( @@ -212,6 +215,41 @@ export default defineAction({ if (isComputedPropertyType(lockedType)) { throw new Error("Computed properties cannot be edited."); } + const isNaturalKey = lockedDatabase.naturalKeyPropertyId === propertyId; + if (isNaturalKey) { + let parsed: unknown; + try { + parsed = JSON.parse(valueJson); + } catch { + parsed = null; + } + if (typeof parsed !== "string" || !parsed.trim()) { + throw new Error( + "A database natural key must remain a non-empty string.", + ); + } + const [existingNaturalKeyClaim] = await tx + .select({ + keyValueJson: schema.contentDatabaseItemKeyClaims.keyValueJson, + }) + .from(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id), + eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId), + eq(schema.contentDatabaseItemKeyClaims.documentId, documentId), + ), + ) + .limit(1); + if ( + existingNaturalKeyClaim && + existingNaturalKeyClaim.keyValueJson !== valueJson + ) { + throw new Error( + "A claimed database natural key cannot be changed. Create a new row instead.", + ); + } + } const [conflictingClaim] = await tx .select({ id: schema.contentDatabaseItemKeyClaims.id }) .from(schema.contentDatabaseItemKeyClaims) @@ -254,6 +292,45 @@ export default defineAction({ updatedAt: now, }); } + if (isNaturalKey) { + await tx + .insert(schema.contentDatabaseItemKeyClaims) + .values({ + id: nanoid(), + ownerEmail: database.ownerEmail, + orgId: database.orgId, + databaseId: database.id, + propertyId, + keyValueJson: valueJson, + itemId: membership.id, + documentId, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing(); + const [claim] = await tx + .select({ + itemId: schema.contentDatabaseItemKeyClaims.itemId, + documentId: schema.contentDatabaseItemKeyClaims.documentId, + }) + .from(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id), + eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId), + eq(schema.contentDatabaseItemKeyClaims.keyValueJson, valueJson), + ), + ); + if ( + !claim || + claim.itemId !== membership.id || + claim.documentId !== documentId + ) { + throw new Error( + "This natural key is already claimed by another database row.", + ); + } + } await tx .delete(schema.contentDatabaseItemKeyClaims) .where( diff --git a/templates/content/actions/space-aware-writers.db.test.ts b/templates/content/actions/space-aware-writers.db.test.ts index fa8cc27868..40f0f7dd23 100644 --- a/templates/content/actions/space-aware-writers.db.test.ts +++ b/templates/content/actions/space-aware-writers.db.test.ts @@ -24,6 +24,7 @@ let schema: Schema; let createDocument: typeof import("./create-document.js").default; let createContentDatabase: typeof import("./create-content-database.js").default; let addDatabaseItem: typeof import("./add-database-item.js").default; +let getContentDatabase: typeof import("./get-content-database.js").default; let organizationContentSpaceId: typeof import("./_content-spaces.js").organizationContentSpaceId; const OWNER = "owner@example.com"; @@ -40,6 +41,7 @@ beforeAll(async () => { createContentDatabase = (await import("./create-content-database.js")) .default; addDatabaseItem = (await import("./add-database-item.js")).default; + getContentDatabase = (await import("./get-content-database.js")).default; ({ organizationContentSpaceId } = await import("./_content-spaces.js")); const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); @@ -246,19 +248,26 @@ describe("space-aware document writers", () => { expect(databaseRow?.spaceId).toBe(pageRow?.spaceId); await expect(filesMemberships(page.id)).resolves.toHaveLength(1); + const discovered = await runWithRequestContext({ userEmail: OWNER }, () => + getContentDatabase.run({ databaseId: converted.database.id }), + ); + if (!("database" in discovered) || !discovered.mutationContract) + throw new Error("Fixture database has no mutation contract."); const row = await runWithRequestContext({ userEmail: OWNER }, () => addDatabaseItem.run({ - databaseId: converted.database.id, + target: discovered.mutationContract!.target, + expectedSchemaRevision: discovered.mutationContract!.schemaRevision, + idempotencyKey: "space-aware-row", title: "Database row", }), ); const [rowDocument] = await getDb() .select({ spaceId: schema.documents.spaceId }) .from(schema.documents) - .where(eq(schema.documents.id, row.createdDocumentId!)); + .where(eq(schema.documents.id, row.receipt.row.documentId)); expect(rowDocument?.spaceId).toBe(pageRow?.spaceId); await expect( - filesMemberships(row.createdDocumentId!), + filesMemberships(row.receipt.row.documentId), ).resolves.toHaveLength(1); }); @@ -291,7 +300,14 @@ describe("space-aware document writers", () => { await expect( runWithRequestContext({ userEmail: OWNER }, () => addDatabaseItem.run({ - databaseId: "legacy-unscoped-database", + target: { + authorityScope: { kind: "personal", id: OWNER }, + spaceId: "fixture-space", + databaseId: "legacy-unscoped-database", + databaseDocumentId: "legacy-unscoped-database-document", + }, + expectedSchemaRevision: "sha256:fixture", + idempotencyKey: "legacy-unscoped-database", title: "Must not be created", }), ), diff --git a/templates/content/actions/update-database-item.ts b/templates/content/actions/update-database-item.ts new file mode 100644 index 0000000000..889c33c15d --- /dev/null +++ b/templates/content/actions/update-database-item.ts @@ -0,0 +1,62 @@ +import { defineAction } from "@agent-native/core"; +import { buildDeepLink } from "@agent-native/core/server"; +import { z } from "zod"; + +import type { ContentDatabaseRowMutationResult } from "../shared/api.js"; +import { + databaseMutationEnvelopeSchema, + updateDatabaseRow, +} from "./_database-row-mutation.js"; + +const schema = databaseMutationEnvelopeSchema.extend({ + itemId: z.string().min(1).describe("Exact database membership row ID"), + documentId: z.string().min(1).describe("Exact row page ID"), + expectedRowRevision: z + .string() + .min(1) + .describe("Row revision returned by get-content-database"), + title: z.string().trim().min(1).max(500).optional(), + propertyValues: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "Sparse strict patch keyed by property definition ID; omitted fields are preserved and explicit null clears a value", + ), +}); + +export default defineAction({ + description: + "Sparsely update one exact Content database row by stable item and document IDs. Requires schema and row revisions, validates every non-Blocks property, and returns a verified idempotent receipt.", + schema, + http: { method: "PUT" }, + audit: { + recordInputs: false, + target: (args) => ({ + type: "document", + id: args.documentId, + visibility: "private", + }), + summary: (_args, result) => { + const receipt = (result as ContentDatabaseRowMutationResult | null) + ?.receipt; + return receipt + ? `${receipt.outcome === "unchanged" ? "Checked" : "Updated"} Content database row ${receipt.row.itemId}` + : "Updated Content database row"; + }, + }, + run: updateDatabaseRow, + link: ({ result }) => { + const documentId = (result as ContentDatabaseRowMutationResult | null) + ?.receipt.row.documentId; + 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/upsert-database-item-by-key.db.test.ts b/templates/content/actions/upsert-database-item-by-key.db.test.ts index 41f6c6f092..dcea0c6487 100644 --- a/templates/content/actions/upsert-database-item-by-key.db.test.ts +++ b/templates/content/actions/upsert-database-item-by-key.db.test.ts @@ -2,7 +2,6 @@ import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { getDbExec } from "@agent-native/core/db"; import { runWithRequestContext } from "@agent-native/core/server"; import { and, eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -11,877 +10,576 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; const TEST_DB_PATH = join( tmpdir(), - `content-key-upsert-${process.pid}-${Date.now()}.sqlite`, + `content-row-mutations-${process.pid}-${Date.now()}.sqlite`, ); +const TEST_DATABASE_URL = + process.env.CONTENT_ROW_MUTATION_POSTGRES_URL ?? `file:${TEST_DB_PATH}`; const OWNER = "owner@example.com"; const OUTSIDER = "outsider@example.com"; -const DATABASE_ONLY_EDITOR = "database-only@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 upsert: typeof import("./upsert-database-item-by-key.js").default; -let setProperty: typeof import("./set-document-property.js").default; -let deleteProperty: typeof import("./delete-document-property.js").default; -let deleteDocument: typeof import("./delete-document.js").default; -let permanentlyDeleteDocument: typeof import("./permanently-delete-document.js").default; +let getDatabase: typeof import("./get-content-database.js").default; +let createRow: typeof import("./add-database-item.js").default; +let updateRow: typeof import("./update-database-item.js").default; +let upsertRow: typeof import("./upsert-database-item-by-key.js").default; -const asOwner = (fn: () => Promise) => - runWithRequestContext({ userEmail: OWNER }, fn); +const asOwner = (run: () => Promise) => + runWithRequestContext({ userEmail: OWNER }, run); beforeAll(async () => { - process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; + if (TEST_DATABASE_URL.startsWith("postgres")) { + const databaseName = new URL(TEST_DATABASE_URL).pathname.toLowerCase(); + if (!databaseName.includes("test")) { + throw new Error( + "CONTENT_ROW_MUTATION_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; - upsert = (await import("./upsert-database-item-by-key.js")).default; - setProperty = (await import("./set-document-property.js")).default; - deleteProperty = (await import("./delete-document-property.js")).default; - deleteDocument = (await import("./delete-document.js")).default; - permanentlyDeleteDocument = (await import("./permanently-delete-document.js")) - .default; + getDatabase = (await import("./get-content-database.js")).default; + createRow = (await import("./add-database-item.js")).default; + updateRow = (await import("./update-database-item.js")).default; + upsertRow = (await import("./upsert-database-item-by-key.js")).default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); }, 60_000); afterAll(() => { - for (const suffix of ["", "-shm", "-wal"]) - rmSync(`${TEST_DB_PATH}${suffix}`, { force: true }); + if (TEST_DATABASE_URL.startsWith("file:")) { + for (const suffix of ["", "-shm", "-wal"]) + rmSync(`${TEST_DB_PATH}${suffix}`, { force: true }); + } }); async function fixture() { const created = await asOwner(() => - createDatabase.run({ title: "Projection" }), + createDatabase.run({ title: "Reliable rows" }), ); - const property = await asOwner(() => + return { + databaseId: created.database.id, + databaseDocumentId: created.database.documentId, + }; +} + +async function contract(databaseId: string) { + const response = await asOwner(() => getDatabase.run({ databaseId })); + if (!("database" in response) || !response.mutationContract) + throw new Error("Fixture database has no mutation contract."); + return response.mutationContract; +} + +function envelope( + discovered: Awaited>, + idempotencyKey: string, +) { + return { + target: { + authorityScope: discovered.target.authorityScope, + spaceId: discovered.target.spaceId, + databaseId: discovered.target.databaseId, + databaseDocumentId: discovered.target.databaseDocumentId, + }, + expectedSchemaRevision: discovered.schemaRevision, + idempotencyKey, + }; +} + +async function addProperty(args: { + databaseId: string; + databaseDocumentId: string; + name: string; + type: + | "text" + | "number" + | "select" + | "multi_select" + | "status" + | "date" + | "person" + | "place" + | "files_media" + | "checkbox" + | "url" + | "email" + | "phone" + | "id"; + options?: any; + naturalKey?: boolean; +}) { + const response = await asOwner(() => configureProperty.run({ - documentId: created.database.documentId, - databaseId: created.database.id, - name: "External key", - type: "text", + documentId: args.databaseDocumentId, + databaseId: args.databaseId, + name: args.name, + type: args.type, + options: args.options, + naturalKey: args.naturalKey, }), ); - const keyProperty = property.properties.find( - (candidate) => candidate.definition.name === "External key", + const property = response.properties.find( + (candidate) => candidate.definition.name === args.name, ); - if (!keyProperty) throw new Error("Fixture key property was not created."); - return { - databaseId: created.database.id, - propertyId: keyProperty.definition.id, - }; + if (!property) throw new Error(`Property ${args.name} was not created.`); + return property.definition.id; } -describe("upsert-database-item-by-key", () => { - it("creates, updates, then reports unchanged with the same stable IDs and a one-row bounded readback", async () => { - const { databaseId, propertyId } = await fixture(); - const created = await asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "capability-7", - title: "First", - body: "initial", - }), - ); - const updated = await asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "capability-7", - title: "Second", - body: "revised", - }), - ); - const unchanged = await asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "capability-7", - title: "Second", - body: "revised", - }), - ); - expect(created.status).toBe("created"); - expect(updated).toMatchObject({ - status: "updated", - itemId: created.itemId, - documentId: created.documentId, - }); - expect(unchanged).toMatchObject({ - status: "unchanged", - itemId: created.itemId, - documentId: created.documentId, - }); - expect(unchanged.readback.items).toHaveLength(1); - expect(unchanged.readback.items[0]?.id).toBe(created.itemId); - expect(unchanged.readback.items[0]?.document).toMatchObject({ - id: created.documentId, - title: "Second", - content: "", +describe("reliable Content database row mutations", () => { + it("discovers an exact target, deterministic writable schema, and row revisions", async () => { + const ids = await fixture(); + const textId = await addProperty({ + ...ids, + name: "Evidence", + type: "text", }); - }); - - it("uses the unique claim for concurrent first writes and preserves inherited privacy", async () => { - const { databaseId, propertyId } = await fixture(); - const [first, second] = await Promise.all([ - asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "race-key", - title: "Race", - }), - ), - asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "race-key", - title: "Race", - }), - ), - ]); - expect(new Set([first.itemId, second.itemId]).size).toBe(1); - const rows = await getDb() - .select() - .from(schema.contentDatabaseItems) - .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); - expect(rows).toHaveLength(1); - const [document] = await getDb() - .select() - .from(schema.documents) - .where(eq(schema.documents.id, first.documentId)); - expect(document?.visibility).toBe("private"); - await expect( - getDb().insert(schema.contentDatabaseItemKeyClaims).values({ - id: "conflicting-active-claim", - ownerEmail: OWNER, - orgId: null, - databaseId, - propertyId, - keyValueJson: '"another-key"', - itemId: first.itemId, - documentId: first.documentId, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }), - ).rejects.toThrow(); - }); + const discovered = await contract(ids.databaseId); - it("serializes conflicting concurrent payloads through their exact readbacks", async () => { - const { databaseId, propertyId } = await fixture(); - const [first, second] = await Promise.all([ - asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "conflicting-race-key", - title: "Payload A", - body: "Body A", - }), - ), - asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "conflicting-race-key", - title: "Payload B", - body: "Body B", + expect(discovered.target).toMatchObject({ + authorityScope: { kind: "personal", id: OWNER }, + databaseId: ids.databaseId, + databaseDocumentId: ids.databaseDocumentId, + }); + expect(discovered.schemaRevision).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(discovered.properties).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: textId, + type: "text", + writable: true, + acceptedShape: "string or null", }), - ), - ]); - - expect(new Set([first.itemId, second.itemId]).size).toBe(1); - expect(new Set([first.documentId, second.documentId]).size).toBe(1); - expect([first.status, second.status].sort()).toEqual([ - "created", - "updated", - ]); - expect(first.readback.items[0]?.document.title).toBe("Payload A"); - expect(second.readback.items[0]?.document.title).toBe("Payload B"); + expect.objectContaining({ type: "blocks", writable: false }), + ]), + ); }); - it("advances updatedAt monotonically for an existing-row body projection", async () => { - const { databaseId, propertyId } = await fixture(); - const created = await asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "body-refresh", - body: "before", + it("creates every supported non-Blocks value without coercion and returns one durable verified receipt", async () => { + const ids = await fixture(); + const propertyIds = { + text: await addProperty({ ...ids, name: "Text", type: "text" }), + number: await addProperty({ ...ids, name: "Number", type: "number" }), + select: await addProperty({ + ...ids, + name: "Select", + type: "select", + options: { + options: [{ id: "one", name: "One", color: "blue" }], + }, }), - ); - const futureUpdatedAt = "2099-01-01T00:00:00.000Z"; - await getDb() - .update(schema.documents) - .set({ updatedAt: futureUpdatedAt }) - .where(eq(schema.documents.id, created.documentId)); - - await asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "body-refresh", - body: "after", + multi: await addProperty({ + ...ids, + name: "Multi", + type: "multi_select", + options: { + options: [ + { id: "a", name: "A", color: "blue" }, + { id: "b", name: "B", color: "green" }, + ], + }, }), - ); - - const [document] = await getDb() - .select({ - content: schema.documents.content, - updatedAt: schema.documents.updatedAt, - }) - .from(schema.documents) - .where(eq(schema.documents.id, created.documentId)); - expect(document?.content).toBe("after"); - expect(document?.updatedAt > futureUpdatedAt).toBe(true); - }); - - it("serializes concurrent writes when an existing row is missing a requested property", async () => { - const { databaseId, propertyId } = await fixture(); - const created = await asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "concurrent-update", + status: await addProperty({ ...ids, name: "Status", type: "status" }), + date: await addProperty({ ...ids, name: "Date", type: "date" }), + person: await addProperty({ ...ids, name: "Person", type: "person" }), + place: await addProperty({ ...ids, name: "Place", type: "place" }), + files: await addProperty({ + ...ids, + name: "Files", + type: "files_media", }), - ); - const [database] = await getDb() - .select() - .from(schema.contentDatabases) - .where(eq(schema.contentDatabases.id, databaseId)); - const configured = await asOwner(() => - configureProperty.run({ - documentId: database.documentId, - databaseId, - name: "Concurrent value", - type: "text", + checked: await addProperty({ + ...ids, + name: "Checked", + type: "checkbox", + }), + url: await addProperty({ ...ids, name: "URL", type: "url" }), + email: await addProperty({ ...ids, name: "Email", type: "email" }), + phone: await addProperty({ ...ids, name: "Phone", type: "phone" }), + }; + const discovered = await contract(ids.databaseId); + const result = await asOwner(() => + createRow.run({ + ...envelope(discovered, "create-all-types"), + title: "Strict row", + propertyValues: { + [propertyIds.text]: "Evidence", + [propertyIds.number]: 42, + [propertyIds.select]: "One", + [propertyIds.multi]: ["b", "A", "b"], + [propertyIds.status]: "not-started", + [propertyIds.date]: { start: "2026-08-10", end: "2026-08-11" }, + [propertyIds.person]: ["alice@example.com"], + [propertyIds.place]: "Indianapolis", + [propertyIds.files]: ["https://example.com/evidence.pdf"], + [propertyIds.checked]: true, + [propertyIds.url]: "https://example.com/feedback/1", + [propertyIds.email]: "person@example.com", + [propertyIds.phone]: "+1 555 0100", + }, }), ); - const requestedProperty = configured.properties.find( - (property) => property.definition.name === "Concurrent value", - ); - if (!requestedProperty) - throw new Error("Concurrent fixture property was not created."); - await Promise.all([ - asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "concurrent-update", - propertyValues: { - [requestedProperty.definition.id]: "same-value", - }, - }), - ), - asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "concurrent-update", - propertyValues: { - [requestedProperty.definition.id]: "same-value", - }, - }), - ), - ]); - - const stored = await getDb() + expect(result.receipt).toMatchObject({ + operation: "create", + outcome: "created", + target: { + databaseId: ids.databaseId, + databaseDocumentId: ids.databaseDocumentId, + }, + idempotency: { key: "create-all-types", result: "applied" }, + readback: { verified: true, title: "Strict row" }, + }); + expect(result.receipt.row.rowRevision).toMatch(/^sha256:/); + expect(result.receipt.readback.propertyValues).toMatchObject({ + [propertyIds.number]: 42, + [propertyIds.select]: "one", + [propertyIds.multi]: ["b", "a"], + [propertyIds.checked]: true, + }); + const receipts = await getDb() .select() - .from(schema.documentPropertyValues) + .from(schema.contentDatabaseRowMutationReceipts) .where( - and( - eq(schema.documentPropertyValues.documentId, created.documentId), - eq( - schema.documentPropertyValues.propertyId, - requestedProperty.definition.id, - ), + eq( + schema.contentDatabaseRowMutationReceipts.databaseId, + ids.databaseId, ), ); - expect(stored).toHaveLength(1); - expect(stored[0]?.valueJson).toBe('"same-value"'); - }); - - it("recollects rows created at the database-lock boundary before trashing", async () => { - const { databaseId, propertyId } = await fixture(); - const [database] = await getDb() - .select() - .from(schema.contentDatabases) - .where(eq(schema.contentDatabases.id, databaseId)); - const suffix = databaseId.replace(/[^a-zA-Z0-9_]/g, "_"); - const triggerName = `late_upsert_${suffix}`; - const documentId = `late_doc_${suffix}`; - const itemId = `late_item_${suffix}`; - const now = new Date().toISOString(); - await getDbExec().execute( - `CREATE TRIGGER ${triggerName} - BEFORE UPDATE ON content_databases - WHEN NEW.id = '${databaseId}' - AND NOT EXISTS (SELECT 1 FROM documents WHERE id = '${documentId}') - BEGIN - INSERT INTO documents - (id, owner_email, parent_id, title, content, position, created_at, updated_at) - VALUES - ('${documentId}', '${OWNER}', '${database.documentId}', 'Late row', '', 0, '${now}', '${now}'); - INSERT INTO content_database_items - (id, owner_email, database_id, document_id, position, created_at, updated_at) - VALUES - ('${itemId}', '${OWNER}', '${databaseId}', '${documentId}', 0, '${now}', '${now}'); - INSERT INTO document_property_values - (id, owner_email, document_id, property_id, value_json, created_at, updated_at) - VALUES - ('late_value_${suffix}', '${OWNER}', '${documentId}', '${propertyId}', '"late-key"', '${now}', '${now}'); - END`, - ); - try { - await asOwner(() => deleteDocument.run({ id: database.documentId })); - } finally { - await getDbExec().execute(`DROP TRIGGER IF EXISTS ${triggerName}`); - } - - const [lateDocument] = await getDb() - .select({ - trashedAt: schema.documents.trashedAt, - trashRootId: schema.documents.trashRootId, - }) - .from(schema.documents) - .where(eq(schema.documents.id, documentId)); - expect(lateDocument?.trashedAt).toBeTruthy(); - expect(lateDocument?.trashRootId).toBe(database.documentId); + expect(receipts).toHaveLength(1); + expect(receipts[0]).toMatchObject({ + itemId: result.receipt.row.itemId, + documentId: result.receipt.row.documentId, + postRowRevision: result.receipt.row.rowRevision, + }); }); - it("fails closed when a key definition is deleted at the database-lock boundary", async () => { - const { databaseId, propertyId } = await fixture(); - const suffix = databaseId.replace(/[^a-zA-Z0-9_]/g, "_"); - const triggerName = `delete_key_definition_${suffix}`; - await getDbExec().execute( - `CREATE TRIGGER ${triggerName} - BEFORE UPDATE ON content_databases - WHEN NEW.id = '${databaseId}' - AND EXISTS ( - SELECT 1 FROM document_property_definitions WHERE id = '${propertyId}' - ) - BEGIN - DELETE FROM document_property_definitions WHERE id = '${propertyId}'; - END`, + it("fails loudly and atomically for unknown, computed, Blocks, and invalid structured values", async () => { + const ids = await fixture(); + const numberId = await addProperty({ + ...ids, + name: "Number", + type: "number", + }); + const computedId = await addProperty({ + ...ids, + name: "Computed", + type: "id", + }); + const response = await asOwner(() => + getDatabase.run({ databaseId: ids.databaseId }), ); - try { + if (!("database" in response) || !response.mutationContract) + throw new Error("Missing mutation contract."); + const blocksId = response.properties.find( + (property) => property.definition.type === "blocks", + )!.definition.id; + + for (const [key, value, code] of [ + ["missing", "value", "UNKNOWN_PROPERTY"], + [computedId, "value", "PROPERTY_NOT_WRITABLE"], + [blocksId, "body", "PROPERTY_NOT_WRITABLE"], + [numberId, "42", "INVALID_PROPERTY_VALUE"], + ] as const) { await expect( asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "deleted-during-upsert", + createRow.run({ + ...envelope(response.mutationContract!, `invalid-${key}`), + propertyValues: { [key]: value }, }), ), - ).rejects.toThrow("changed or was deleted"); - } finally { - await getDbExec().execute(`DROP TRIGGER IF EXISTS ${triggerName}`); + ).rejects.toMatchObject({ errorCode: code }); } - - const claims = await getDb() - .select() - .from(schema.contentDatabaseItemKeyClaims) - .where(eq(schema.contentDatabaseItemKeyClaims.databaseId, databaseId)); - const items = await getDb() + const rows = await getDb() .select() .from(schema.contentDatabaseItems) - .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); - expect(claims).toHaveLength(0); - expect(items).toHaveLength(0); + .where(eq(schema.contentDatabaseItems.databaseId, ids.databaseId)); + expect(rows).toHaveLength(0); }); - it("verifies every requested property by canonical serialized readback, including arrays and objects", async () => { - const { databaseId, propertyId } = await fixture(); - const [database] = await getDb() - .select() - .from(schema.contentDatabases) - .where(eq(schema.contentDatabases.id, databaseId)); - const multi = await asOwner(() => - configureProperty.run({ - documentId: database.documentId, - databaseId, - name: "Labels", - type: "multi_select", - options: { - options: [ - { id: "alpha", name: "Alpha", color: "blue" }, - { id: "beta", name: "Beta", color: "green" }, - ], - }, - }), - ); - const date = await asOwner(() => - configureProperty.run({ - documentId: database.documentId, - databaseId, - name: "Window", - type: "date", + it("sparsely updates exact IDs, preserves body and omitted fields, and rejects stale row CAS", async () => { + const ids = await fixture(); + const firstId = await addProperty({ ...ids, name: "First", type: "text" }); + const secondId = await addProperty({ + ...ids, + name: "Second", + type: "text", + }); + const discovered = await contract(ids.databaseId); + const created = await asOwner(() => + createRow.run({ + ...envelope(discovered, "sparse-create"), + title: "Before", + propertyValues: { [firstId]: "keep", [secondId]: "change" }, }), ); - const multiProperty = multi.properties.find( - (property) => property.definition.name === "Labels", - ); - const dateProperty = date.properties.find( - (property) => property.definition.name === "Window", - ); - if (!multiProperty || !dateProperty) - throw new Error("Fixture properties were not created."); - - const result = await asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "rich-readback", - title: "Typed values", - body: "verified body", - propertyValues: { - [multiProperty.definition.id]: ["alpha", "beta"], - [dateProperty.definition.id]: { - start: "2026-08-02", - end: "2026-08-03", - includeTime: false, - }, - }, + await getDb() + .update(schema.documents) + .set({ content: "Blocks body stays separate" }) + .where(eq(schema.documents.id, created.receipt.row.documentId)); + const refreshed = await contract(ids.databaseId); + const updated = await asOwner(() => + updateRow.run({ + ...envelope(refreshed, "sparse-update"), + itemId: created.receipt.row.itemId, + documentId: created.receipt.row.documentId, + expectedRowRevision: created.receipt.row.rowRevision, + title: "After", + propertyValues: { [secondId]: null }, }), ); - const values = new Map( - result.readback.items[0]?.properties.map((property) => [ - property.definition.id, - property.value, - ]), - ); - expect(values.get(propertyId)).toBe("rich-readback"); - expect(values.get(multiProperty.definition.id)).toEqual(["alpha", "beta"]); - expect(values.get(dateProperty.definition.id)).toEqual({ - start: "2026-08-02", - end: "2026-08-03", - includeTime: false, + expect(updated.receipt).toMatchObject({ + outcome: "updated", + affected: { title: true, propertyIds: [secondId] }, + readback: { + title: "After", + propertyValues: { [firstId]: "keep", [secondId]: null }, + }, }); - }); - - it("denies access and fails closed for wrong-database or computed key properties", async () => { - const { databaseId, propertyId } = await fixture(); - await expect( - runWithRequestContext({ userEmail: OUTSIDER }, () => - upsert.run({ databaseId, keyPropertyId: propertyId, keyValue: "nope" }), - ), - ).rejects.toThrow(); - await expect( - asOwner(() => - upsert.run({ databaseId, keyPropertyId: "missing", keyValue: "nope" }), - ), - ).rejects.toThrow("does not belong"); - const [definition] = await getDb() + const [document] = await getDb() .select() - .from(schema.documentPropertyDefinitions) - .where( - and( - eq(schema.documentPropertyDefinitions.id, propertyId), - eq(schema.documentPropertyDefinitions.databaseId, databaseId), - ), - ); - await getDb() - .update(schema.documentPropertyDefinitions) - .set({ type: "formula" }) - .where(eq(schema.documentPropertyDefinitions.id, definition.id)); - await expect( - asOwner(() => - upsert.run({ databaseId, keyPropertyId: propertyId, keyValue: "nope" }), - ), - ).rejects.toThrow("cannot be used"); - }); + .from(schema.documents) + .where(eq(schema.documents.id, created.receipt.row.documentId)); + expect(document.content).toBe("Blocks body stays separate"); - it("rejects system database memberships", async () => { - const { databaseId, propertyId } = await fixture(); - await getDb() - .update(schema.contentDatabases) - .set({ systemRole: "test-system-database" }) - .where(eq(schema.contentDatabases.id, databaseId)); await expect( asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "not-a-system-membership", + updateRow.run({ + ...envelope(refreshed, "stale-update"), + itemId: created.receipt.row.itemId, + documentId: created.receipt.row.documentId, + expectedRowRevision: created.receipt.row.rowRevision, + title: "Stale overwrite", }), ), - ).rejects.toThrow("ordinary Content databases"); + ).rejects.toMatchObject({ errorCode: "ROW_REVISION_CONFLICT" }); }); - it("rejects a source-managed property as the stable key", async () => { - const { databaseId, propertyId } = await fixture(); - const now = new Date().toISOString(); - await getDb().insert(schema.contentDatabaseSources).values({ - id: "source-managed-key-source", - ownerEmail: OWNER, - databaseId, - sourceType: "test", - sourceName: "Test source", - sourceTable: "test_rows", - createdAt: now, - updatedAt: now, + it("configures one text natural key and creates, replays, then CAS-updates one stable row", async () => { + const ids = await fixture(); + const keyPropertyId = await addProperty({ + ...ids, + name: "Feedback ID", + type: "text", + naturalKey: true, }); - await getDb().insert(schema.contentDatabaseSourceFields).values({ - id: "source-managed-key-field", - ownerEmail: OWNER, - sourceId: "source-managed-key-source", - propertyId, - localFieldKey: propertyId, - sourceFieldKey: "external_id", - sourceFieldLabel: "External ID", - sourceFieldType: "text", - createdAt: now, - updatedAt: now, + const evidenceId = await addProperty({ + ...ids, + name: "Evidence", + type: "text", }); - await expect( asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "source-owned", + configureProperty.run({ + id: evidenceId, + documentId: ids.databaseDocumentId, + databaseId: ids.databaseId, + name: "Evidence", + type: "text", + naturalKey: true, }), ), - ).rejects.toThrow("cannot be used as a stable key"); - const claims = await getDb() - .select() - .from(schema.contentDatabaseItemKeyClaims) - .where(eq(schema.contentDatabaseItemKeyClaims.databaseId, databaseId)); - expect(claims).toHaveLength(0); - }); - - it("rejects a source-managed non-key property in propertyValues", async () => { - const { databaseId, propertyId } = await fixture(); - const now = new Date().toISOString(); - const managedPropertyId = "source-managed-payload-property"; - await getDb().insert(schema.documentPropertyDefinitions).values({ - id: managedPropertyId, - ownerEmail: OWNER, - databaseId, - name: "Source Status", - type: "text", - visibility: "always_show", - optionsJson: "{}", - position: 1, - createdAt: now, - updatedAt: now, + ).rejects.toThrow("Clear the existing database natural key"); + const discovered = await contract(ids.databaseId); + expect(discovered.naturalKeyPropertyId).toBe(keyPropertyId); + const input = { + ...envelope(discovered, "feedback-upsert-1"), + keyValue: "feedback-001", + expectedRowRevision: null, + title: "Feedback", + propertyValues: { [evidenceId]: "first" }, + }; + const created = await asOwner(() => upsertRow.run(input)); + const replayed = await asOwner(() => upsertRow.run(input)); + expect(replayed.receipt).toMatchObject({ + outcome: "created", + row: created.receipt.row, + idempotency: { result: "replayed" }, }); - await getDb().insert(schema.contentDatabaseSources).values({ - id: "source-managed-payload-source", - ownerEmail: OWNER, - databaseId, - sourceType: "test", - sourceName: "Payload source", - sourceTable: "test_rows", - createdAt: now, - updatedAt: now, - }); - await getDb().insert(schema.contentDatabaseSourceFields).values({ - id: "source-managed-payload-field", - ownerEmail: OWNER, - sourceId: "source-managed-payload-source", - propertyId: managedPropertyId, - localFieldKey: managedPropertyId, - sourceFieldKey: "status", - sourceFieldLabel: "Status", - sourceFieldType: "text", - createdAt: now, - updatedAt: now, - }); - await expect( - asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "payload-source-owned", - propertyValues: { [managedPropertyId]: "caller overwrite" }, - }), - ), - ).rejects.toThrow(/source-managed and cannot be written/i); - const memberships = await getDb() - .select({ id: schema.contentDatabaseItems.id }) - .from(schema.contentDatabaseItems) - .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); - expect(memberships).toEqual([]); - }); + asOwner(() => upsertRow.run({ ...input, title: "Different" })), + ).rejects.toMatchObject({ errorCode: "IDEMPOTENCY_KEY_REUSED" }); - it("does not mutate an existing row when the caller can edit only the database page", async () => { - const { databaseId, propertyId } = await fixture(); - const created = await asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "private-row", - title: "Original", + const updated = await asOwner(() => + upsertRow.run({ + ...envelope(discovered, "feedback-upsert-2"), + keyValue: "feedback-001", + expectedRowRevision: created.receipt.row.rowRevision, + propertyValues: { [evidenceId]: "second" }, }), ); - const [database] = await getDb() - .select() - .from(schema.contentDatabases) - .where(eq(schema.contentDatabases.id, databaseId)); - await getDb().insert(schema.documentShares).values({ - id: "database-only-editor-share", - resourceId: database.documentId, - principalType: "user", - principalId: DATABASE_ONLY_EDITOR, - role: "editor", - createdBy: OWNER, - createdAt: new Date().toISOString(), + expect(updated.receipt).toMatchObject({ + outcome: "updated", + row: { + itemId: created.receipt.row.itemId, + documentId: created.receipt.row.documentId, + }, + readback: { + propertyValues: { + [keyPropertyId]: "feedback-001", + [evidenceId]: "second", + }, + }, }); - await expect( - runWithRequestContext({ userEmail: DATABASE_ONLY_EDITOR }, () => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "private-row", - title: "Mutated", - }), - ), - ).rejects.toThrow(); - const [document] = await getDb() - .select({ title: schema.documents.title }) - .from(schema.documents) - .where(eq(schema.documents.id, created.documentId)); - expect(document?.title).toBe("Original"); }); - it("fails closed when a stable-key claim no longer names its exact database membership", async () => { - const { databaseId, propertyId } = await fixture(); + it("keeps configured natural-key claims consistent across create and exact update", async () => { + const ids = await fixture(); + const keyPropertyId = await addProperty({ + ...ids, + name: "Feedback ID", + type: "text", + naturalKey: true, + }); + const discovered = await contract(ids.databaseId); const created = await asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "stale-claim", - title: "Original", + createRow.run({ + ...envelope(discovered, "natural-key-create"), + propertyValues: { [keyPropertyId]: "feedback-created-directly" }, }), ); - await getDb() - .update(schema.contentDatabaseItemKeyClaims) - .set({ itemId: "missing-item" }) - .where( - and( - eq(schema.contentDatabaseItemKeyClaims.databaseId, databaseId), - eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId), - ), - ); - await getDb() - .delete(schema.documentPropertyValues) - .where( - and( - eq(schema.documentPropertyValues.documentId, created.documentId), - eq(schema.documentPropertyValues.propertyId, propertyId), - ), - ); await expect( asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "stale-claim", - title: "Would mutate if claim were trusted", + upsertRow.run({ + ...envelope(discovered, "natural-key-upsert-collision"), + keyValue: "feedback-created-directly", + expectedRowRevision: null, }), ), - ).rejects.toThrow("no longer matches the stored key property"); - const [document] = await getDb() - .select({ title: schema.documents.title }) - .from(schema.documents) - .where(eq(schema.documents.id, created.documentId)); - expect(document?.title).toBe("Original"); - }); - - it("atomically retires A when an ordinary property edit changes it to B", async () => { - const { databaseId, propertyId } = await fixture(); - const created = await asOwner(() => - upsert.run({ databaseId, keyPropertyId: propertyId, keyValue: "A" }), - ); - await asOwner(() => - setProperty.run({ - documentId: created.documentId, - databaseId, - propertyId, - value: "B", - }), - ); - const replacement = await asOwner(() => - upsert.run({ databaseId, keyPropertyId: propertyId, keyValue: "A" }), - ); - expect(replacement.status).toBe("created"); - expect(replacement.documentId).not.toBe(created.documentId); - const b = await asOwner(() => - upsert.run({ databaseId, keyPropertyId: propertyId, keyValue: "B" }), - ); - expect(b.documentId).toBe(created.documentId); - }); - - it("serializes a real concurrent ordinary write with stable-key upsert", async () => { - const { databaseId, propertyId } = await fixture(); - const created = await asOwner(() => - upsert.run({ databaseId, keyPropertyId: propertyId, keyValue: "A" }), - ); - - await Promise.allSettled([ - asOwner(() => - upsert.run({ databaseId, keyPropertyId: propertyId, keyValue: "A" }), - ), + ).rejects.toMatchObject({ errorCode: "ROW_ALREADY_EXISTS" }); + await expect( asOwner(() => - setProperty.run({ - documentId: created.documentId, - databaseId, - propertyId, - value: "B", + updateRow.run({ + ...envelope(discovered, "natural-key-update"), + itemId: created.receipt.row.itemId, + documentId: created.receipt.row.documentId, + expectedRowRevision: created.receipt.row.rowRevision, + propertyValues: { [keyPropertyId]: "feedback-renamed" }, }), ), - ]); - - const aValues = await getDb() - .select({ documentId: schema.documentPropertyValues.documentId }) - .from(schema.documentPropertyValues) - .where( - and( - eq(schema.documentPropertyValues.propertyId, propertyId), - eq(schema.documentPropertyValues.valueJson, '"A"'), - ), - ); - const aClaims = await getDb() - .select({ documentId: schema.contentDatabaseItemKeyClaims.documentId }) - .from(schema.contentDatabaseItemKeyClaims) - .where( - and( - eq(schema.contentDatabaseItemKeyClaims.databaseId, databaseId), - eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId), - eq(schema.contentDatabaseItemKeyClaims.keyValueJson, '"A"'), - ), - ); - expect(aValues.length).toBeLessThanOrEqual(1); - expect(aClaims).toEqual(aValues); + ).rejects.toMatchObject({ errorCode: "NATURAL_KEY_IMMUTABLE" }); }); - it("rejects an ordinary edit that collides with another claimed key", async () => { - const { databaseId, propertyId } = await fixture(); - const a = await asOwner(() => - upsert.run({ databaseId, keyPropertyId: propertyId, keyValue: "A" }), - ); - await asOwner(() => - upsert.run({ databaseId, keyPropertyId: propertyId, keyValue: "B" }), - ); - + it("rejects stale schema, target mismatch, duplicate natural-key configuration, and unauthorized writes without side effects", async () => { + const ids = await fixture(); + const keyId = await addProperty({ + ...ids, + name: "Candidate key", + type: "text", + }); + const stale = await contract(ids.databaseId); + await addProperty({ ...ids, name: "Schema drift", type: "text" }); await expect( asOwner(() => - setProperty.run({ - documentId: a.documentId, - databaseId, - propertyId, - value: "B", + createRow.run({ + ...envelope(stale, "stale-schema"), + title: "No write", }), ), - ).rejects.toThrow(/already claimed as another row's stable key/i); - - const values = await getDb() - .select({ valueJson: schema.documentPropertyValues.valueJson }) - .from(schema.documentPropertyValues) - .where( - and( - eq(schema.documentPropertyValues.documentId, a.documentId), - eq(schema.documentPropertyValues.propertyId, propertyId), - ), - ); - expect(values).toEqual([{ valueJson: '"A"' }]); - }); - - it("serializes a real concurrent type change and retires old-type claims", async () => { - const { databaseId, propertyId } = await fixture(); - const [database] = await getDb() - .select() - .from(schema.contentDatabases) - .where(eq(schema.contentDatabases.id, databaseId)); - - const [upsertResult, configureResult] = await Promise.allSettled([ + ).rejects.toMatchObject({ errorCode: "SCHEMA_REVISION_CONFLICT" }); + const current = await contract(ids.databaseId); + await expect( asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "not-a-number", + createRow.run({ + ...envelope(current, "wrong-target"), + target: { ...envelope(current, "unused").target, spaceId: "wrong" }, }), ), - asOwner(() => - configureProperty.run({ - id: propertyId, - documentId: database.documentId, - databaseId, - name: "External key", - type: "number", - }), + ).rejects.toMatchObject({ errorCode: "TARGET_MISMATCH" }); + await expect( + runWithRequestContext({ userEmail: OUTSIDER }, () => + createRow.run({ ...envelope(current, "outsider"), title: "Denied" }), ), - ]); - expect(configureResult.status).toBe("fulfilled"); - expect(["fulfilled", "rejected"]).toContain(upsertResult.status); - - const [definition] = await getDb() - .select({ type: schema.documentPropertyDefinitions.type }) - .from(schema.documentPropertyDefinitions) - .where(eq(schema.documentPropertyDefinitions.id, propertyId)); - const claims = await getDb() - .select() - .from(schema.contentDatabaseItemKeyClaims) - .where(eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId)); - const values = await getDb() - .select() - .from(schema.documentPropertyValues) - .where(eq(schema.documentPropertyValues.propertyId, propertyId)); - expect(definition?.type).toBe("number"); - expect(claims).toHaveLength(0); - expect(values).toHaveLength(0); - }); + ).rejects.toThrow(); - it("removes stable-key claims in the same property-definition deletion", async () => { - const { databaseId, propertyId } = await fixture(); - const created = await asOwner(() => - upsert.run({ databaseId, keyPropertyId: propertyId, keyValue: "gone" }), - ); - await asOwner(() => - deleteProperty.run({ - documentId: created.documentId, - databaseId, - propertyId, + const first = await asOwner(() => + createRow.run({ + ...envelope(current, "duplicate-key-1"), + propertyValues: { [keyId]: "duplicate" }, }), ); - const claims = await getDb() - .select() - .from(schema.contentDatabaseItemKeyClaims) - .where(eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId)); - expect(claims).toHaveLength(0); - }); - - it("releases claims during permanent database-row cleanup so the key can be reused", async () => { - const { databaseId, propertyId } = await fixture(); - const created = await asOwner(() => - upsert.run({ databaseId, keyPropertyId: propertyId, keyValue: "reuse" }), + const second = await asOwner(() => + createRow.run({ + ...envelope(current, "duplicate-key-2"), + propertyValues: { [keyId]: "duplicate" }, + }), ); - await asOwner(() => deleteDocument.run({ id: created.documentId })); + expect(first.receipt.row.itemId).not.toBe(second.receipt.row.itemId); await expect( asOwner(() => - upsert.run({ - databaseId, - keyPropertyId: propertyId, - keyValue: "reuse", + configureProperty.run({ + id: keyId, + documentId: ids.databaseDocumentId, + databaseId: ids.databaseId, + name: "Candidate key", + type: "text", + naturalKey: true, }), ), - ).rejects.toThrow("trashed database row"); - await asOwner(() => - permanentlyDeleteDocument.run({ id: created.documentId }), - ); - const reused = await asOwner(() => - upsert.run({ databaseId, keyPropertyId: propertyId, keyValue: "reuse" }), + ).rejects.toThrow("more than one row"); + const [database] = await getDb() + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, ids.databaseId)); + expect(database.naturalKeyPropertyId).toBeNull(); + const rows = await getDb() + .select() + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, ids.databaseId)); + expect(rows).toHaveLength(2); + const unintended = rows.filter( + (row: any) => + row.documentId !== first.receipt.row.documentId && + row.documentId !== second.receipt.row.documentId, ); - expect(reused.status).toBe("created"); - expect(reused.documentId).not.toBe(created.documentId); + expect(unintended).toHaveLength(0); + }); + + it("serializes concurrent retries to one side effect and one receipt", async () => { + const ids = await fixture(); + const discovered = await contract(ids.databaseId); + const input = { + ...envelope(discovered, "concurrent-create"), + title: "Exactly once", + }; + const [first, second] = await Promise.all([ + asOwner(() => createRow.run(input)), + asOwner(() => createRow.run(input)), + ]); + expect(first.receipt.row).toEqual(second.receipt.row); + expect( + new Set([ + first.receipt.idempotency.result, + second.receipt.idempotency.result, + ]), + ).toEqual(new Set(["applied", "replayed"])); + const rows = await getDb() + .select() + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, ids.databaseId)); + const receipts = await getDb() + .select() + .from(schema.contentDatabaseRowMutationReceipts) + .where( + and( + eq( + schema.contentDatabaseRowMutationReceipts.databaseId, + ids.databaseId, + ), + eq( + schema.contentDatabaseRowMutationReceipts.idempotencyKey, + "concurrent-create", + ), + ), + ); + expect(rows).toHaveLength(1); + expect(receipts).toHaveLength(1); }); }); diff --git a/templates/content/actions/upsert-database-item-by-key.ts b/templates/content/actions/upsert-database-item-by-key.ts index 3de2dae12b..c9a86fb303 100644 --- a/templates/content/actions/upsert-database-item-by-key.ts +++ b/templates/content/actions/upsert-database-item-by-key.ts @@ -1,814 +1,61 @@ import { defineAction } from "@agent-native/core"; -import { writeAppState } from "@agent-native/core/application-state"; -import { - createDbExec, - getDatabaseUrl, - isLocalDatabase, - isPostgres, -} from "@agent-native/core/db"; -import { getRequestUserEmail } from "@agent-native/core/server/request-context"; -import { assertAccess } from "@agent-native/core/sharing"; -import { and, eq, inArray, isNull, ne, sql } from "drizzle-orm"; +import { buildDeepLink } from "@agent-native/core/server"; import { z } from "zod"; -import { getDb, schema } from "../server/db/index.js"; -import { - isBlocksPropertyType, - isComputedPropertyType, - type DocumentPropertyType, -} from "../shared/properties.js"; -import { ensureDocumentFilesMembership } from "./_content-files.js"; -import { getContentDatabaseResponse } from "./_database-utils.js"; +import type { ContentDatabaseRowMutationResult } from "../shared/api.js"; import { - databaseItemsPositionScope, - documentsPositionScope, - withPositionLock, -} from "./_position-utils.js"; -import { nanoid, normalizedValueJson } from "./_property-utils.js"; -import getDocument from "./get-document.js"; + databaseMutationEnvelopeSchema, + upsertDatabaseRow, +} from "./_database-row-mutation.js"; -const upsertSchema = z.object({ - databaseId: z.string().min(1).describe("Target Content database ID"), - keyPropertyId: z +const schema = databaseMutationEnvelopeSchema.extend({ + keyValue: z.string().min(1).describe("Value of the configured natural key"), + expectedRowRevision: z .string() .min(1) - .describe("Database property definition ID used as the stable key"), - keyValue: z.string().min(1).describe("Non-empty stable key value"), - title: z - .string() - .max(500) - .optional() - .describe("Row title to create or update"), - body: z.string().optional().describe("Row body to create or update"), + .nullable() + .describe( + "Use null to assert the key is absent and create; use the discovered row revision to update an existing key", + ), + title: z.string().trim().min(1).max(500).optional(), propertyValues: z .record(z.string(), z.unknown()) .optional() - .describe("Property values keyed by property definition ID"), + .describe("Sparse strict values keyed by property definition ID"), }); -type Identity = { itemId: string; documentId: string }; - -async function withStableKeyReadbackLock( - scope: string, - run: () => Promise, -): Promise { - const runInProcess = () => - withPositionLock(`stableKeyReadback:${scope}`, run); - - // Every dialect needs one lock that spans both the write transaction and - // the exact post-commit readback; otherwise a later writer can legitimately - // overtake the first request between those phases and turn a committed - // mutation into an ambiguous 500 receipt. Real PostgreSQL workers also need - // the advisory lock because their in-process promise chains are independent. - if (!isPostgres() || isLocalDatabase()) return runInProcess(); - - // Never hold an advisory lock in the ordinary shared application pool: a - // small burst could occupy every connection while the lock winner still - // needs that pool to perform its write and readback. One process-wide gate - // bounds this action to one disposable lock connection per process while - // PostgreSQL coordinates the same scope across independent processes. - return withPositionLock("stableKeyReadback:postgres-connection", async () => { - const lockDb = await createDbExec({ url: getDatabaseUrl() }); - try { - if (!lockDb.transaction) { - throw new Error("PostgreSQL stable-key locking requires transactions."); - } - return await lockDb.transaction(async (tx) => { - await tx.execute({ - sql: "SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", - args: [scope], - }); - return runInProcess(); - }); - } finally { - await lockDb.close?.(); - } - }); -} - export default defineAction({ description: - "Atomically create or update one Content database row by a database-scoped stable property key. Returns a created, updated, or unchanged receipt with stable item and document IDs.", - schema: upsertSchema, - run: async ({ - databaseId, - keyPropertyId, - keyValue, - title, - body, - propertyValues, - }) => { - const db = getDb(); - const [database] = await db - .select() - .from(schema.contentDatabases) - .where( - and( - eq(schema.contentDatabases.id, databaseId), - isNull(schema.contentDatabases.deletedAt), - ), - ); - if (!database) throw new Error(`Database "${databaseId}" not found.`); - if (database.systemRole) { - throw new Error( - "Stable-key upserts are supported only for ordinary Content databases, not system databases.", - ); - } - - const access = await assertAccess( - "document", - database.documentId, - "editor", - ); - const databaseDocument = access.resource; - const databaseSpaceId = database.spaceId ?? databaseDocument.spaceId; - if (!databaseSpaceId) - throw new Error("Database does not belong to a Content space."); - if ( - database.spaceId && - databaseDocument.spaceId && - database.spaceId !== databaseDocument.spaceId - ) { - throw new Error( - `Database "${databaseId}" has inconsistent Content space.`, - ); - } - - const definitions = await db - .select() - .from(schema.documentPropertyDefinitions) - .where( - and( - eq(schema.documentPropertyDefinitions.databaseId, databaseId), - eq( - schema.documentPropertyDefinitions.ownerEmail, - database.ownerEmail, - ), - ), - ); - const definitionsById = new Map( - definitions.map((definition) => [definition.id, definition]), - ); - const keyDefinition = definitionsById.get(keyPropertyId); - if (!keyDefinition) - throw new Error( - `Key property "${keyPropertyId}" does not belong to database "${databaseId}".`, - ); - const keyType = keyDefinition.type as DocumentPropertyType; - const [sourceManagedKey] = await db - .select({ id: schema.contentDatabaseSourceFields.id }) - .from(schema.contentDatabaseSourceFields) - .innerJoin( - schema.contentDatabaseSources, - eq( - schema.contentDatabaseSources.id, - schema.contentDatabaseSourceFields.sourceId, - ), - ) - .where( - and( - eq(schema.contentDatabaseSources.databaseId, databaseId), - eq(schema.contentDatabaseSourceFields.propertyId, keyPropertyId), - ), - ) - .limit(1); - if ( - keyDefinition.systemRole || - sourceManagedKey || - isComputedPropertyType(keyType) || - isBlocksPropertyType(keyType) - ) { - throw new Error( - `Property "${keyDefinition.name}" cannot be used as a stable key.`, - ); - } - const keyValueJson = normalizedValueJson(keyType, keyValue); - if (keyValueJson === "null" || keyValueJson === '\"\"') - throw new Error("Stable key value must normalize to a non-empty value."); - - // prettier-ignore - return withStableKeyReadbackLock( - JSON.stringify([databaseId, keyPropertyId, keyValueJson]), - async () => { - const values = new Map(); - for (const [propertyId, value] of Object.entries(propertyValues ?? {})) { - const definition = definitionsById.get(propertyId); - if (!definition) - throw new Error( - `Property "${propertyId}" does not belong to database "${databaseId}".`, - ); - const type = definition.type as DocumentPropertyType; - if ( - definition.systemRole || - isComputedPropertyType(type) || - isBlocksPropertyType(type) - ) { - throw new Error( - `Property "${definition.name}" cannot be written by this action.`, - ); - } - const valueJson = normalizedValueJson(type, value); - if (propertyId === keyPropertyId && valueJson !== keyValueJson) { - throw new Error( - "propertyValues must not change keyPropertyId away from keyValue.", - ); - } - values.set(propertyId, valueJson); - } - values.set(keyPropertyId, keyValueJson); - - const now = new Date().toISOString(); - const proposed: Identity = { itemId: nanoid(), documentId: nanoid() }; - const inheritedShares = await db - .select({ - principalType: schema.documentShares.principalType, - principalId: schema.documentShares.principalId, - role: schema.documentShares.role, - }) - .from(schema.documentShares) - .where(eq(schema.documentShares.resourceId, database.documentId)); - - const result = await withPositionLock( - documentsPositionScope(database.ownerEmail, database.documentId), - () => - withPositionLock(databaseItemsPositionScope(databaseId), () => - db.transaction(async (tx) => { - // Serialize against trash/permanent-delete transactions and - // revalidate the exact database after acquiring the row lock. A - // pre-transaction access check alone can become stale while an - // unattended projection is waiting to write. - const [lockedDatabase] = await tx - .update(schema.contentDatabases) - .set({ - updatedAt: sql`${schema.contentDatabases.updatedAt}`, - }) - .where( - and( - eq(schema.contentDatabases.id, databaseId), - eq(schema.contentDatabases.documentId, database.documentId), - eq(schema.contentDatabases.ownerEmail, database.ownerEmail), - isNull(schema.contentDatabases.deletedAt), - ), - ) - .returning({ - id: schema.contentDatabases.id, - systemRole: schema.contentDatabases.systemRole, - }); - if (!lockedDatabase || lockedDatabase.systemRole) { - throw new Error( - `Database "${databaseId}" is no longer an active ordinary Content database.`, - ); - } - - // Lock and revalidate every requested definition after the database - // lock. Property deletion/configuration touches these same rows, so - // either it commits first and this fails closed, or it waits until - // the upsert has committed a self-consistent claim/value set. - const requestedPropertyIds = [...values.keys()]; - const lockedDefinitions = await tx - .update(schema.documentPropertyDefinitions) - .set({ - updatedAt: sql`${schema.documentPropertyDefinitions.updatedAt}`, - }) - .where( - and( - eq(schema.documentPropertyDefinitions.databaseId, databaseId), - eq( - schema.documentPropertyDefinitions.ownerEmail, - database.ownerEmail, - ), - inArray( - schema.documentPropertyDefinitions.id, - requestedPropertyIds, - ), - ), - ) - .returning({ - id: schema.documentPropertyDefinitions.id, - type: schema.documentPropertyDefinitions.type, - systemRole: schema.documentPropertyDefinitions.systemRole, - }); - const lockedDefinitionsById = new Map( - lockedDefinitions.map((definition) => [ - definition.id, - definition, - ]), - ); - for (const propertyId of requestedPropertyIds) { - const initialDefinition = definitionsById.get(propertyId); - const lockedDefinition = lockedDefinitionsById.get(propertyId); - if ( - !initialDefinition || - !lockedDefinition || - lockedDefinition.type !== initialDefinition.type || - lockedDefinition.systemRole !== initialDefinition.systemRole - ) { - throw new Error( - `Property "${propertyId}" changed or was deleted before the stable-key upsert could write.`, - ); - } - const lockedType = lockedDefinition.type as DocumentPropertyType; - if ( - lockedDefinition.systemRole || - isComputedPropertyType(lockedType) || - isBlocksPropertyType(lockedType) - ) { - throw new Error( - `Property "${propertyId}" cannot be written by this action.`, - ); - } - } - - const transactionSourceManagedProperties = await tx - .select({ - propertyId: schema.contentDatabaseSourceFields.propertyId, - }) - .from(schema.contentDatabaseSourceFields) - .innerJoin( - schema.contentDatabaseSources, - eq( - schema.contentDatabaseSources.id, - schema.contentDatabaseSourceFields.sourceId, - ), - ) - .where( - and( - eq(schema.contentDatabaseSources.databaseId, databaseId), - inArray( - schema.contentDatabaseSourceFields.propertyId, - requestedPropertyIds, - ), - ), - ); - if (transactionSourceManagedProperties.length > 0) { - const managedPropertyId = - transactionSourceManagedProperties[0].propertyId; - const managedDefinition = managedPropertyId - ? definitionsById.get(managedPropertyId) - : undefined; - if (managedPropertyId === keyPropertyId) { - throw new Error( - `Property "${keyDefinition.name}" cannot be used as a stable key.`, - ); - } - throw new Error( - `Property "${managedDefinition?.name ?? managedPropertyId}" is source-managed and cannot be written by this action.`, - ); - } - - const matches = await tx - .select({ - itemId: schema.contentDatabaseItems.id, - documentId: schema.contentDatabaseItems.documentId, - trashedAt: schema.documents.trashedAt, - }) - .from(schema.documentPropertyValues) - .innerJoin( - schema.contentDatabaseItems, - eq( - schema.contentDatabaseItems.documentId, - schema.documentPropertyValues.documentId, - ), - ) - .innerJoin( - schema.documents, - eq(schema.documents.id, schema.contentDatabaseItems.documentId), - ) - .where( - and( - eq(schema.contentDatabaseItems.databaseId, databaseId), - eq(schema.documentPropertyValues.propertyId, keyPropertyId), - eq(schema.documentPropertyValues.valueJson, keyValueJson), - ), - ); - const matchByDocument = new Map( - matches.map((row) => [row.documentId, row]), - ); - if (matches.some((row) => row.trashedAt)) - throw new Error( - "Stable key belongs to a trashed database row; restore or resolve it before upserting.", - ); - if ( - matches.length !== matchByDocument.size || - matchByDocument.size > 1 - ) { - throw new Error( - "Stable key matches multiple database rows; reconcile the duplicates before upserting.", - ); - } - - const [claim] = await tx - .select() - .from(schema.contentDatabaseItemKeyClaims) - .where( - and( - eq( - schema.contentDatabaseItemKeyClaims.databaseId, - databaseId, - ), - eq( - schema.contentDatabaseItemKeyClaims.propertyId, - keyPropertyId, - ), - eq( - schema.contentDatabaseItemKeyClaims.keyValueJson, - keyValueJson, - ), - ), - ); - let identity: Identity | undefined = claim && { - itemId: claim.itemId, - documentId: claim.documentId, - }; - const matched = [...matchByDocument.values()][0]; - if ( - claim && - matched && - (claim.itemId !== matched.itemId || - claim.documentId !== matched.documentId) - ) { - throw new Error( - "Stable key claim conflicts with the stored property value; reconcile before upserting.", - ); - } - if (claim && !matched) { - throw new Error( - "Stable key claim no longer matches the stored key property; reconcile before upserting.", - ); - } - - if (!identity && matched) { - const candidate = { - itemId: matched.itemId, - documentId: matched.documentId, - }; - await tx - .insert(schema.contentDatabaseItemKeyClaims) - .values({ - id: nanoid(), - ownerEmail: database.ownerEmail, - orgId: database.orgId, - databaseId, - propertyId: keyPropertyId, - keyValueJson, - ...candidate, - createdAt: now, - updatedAt: now, - }) - .onConflictDoNothing(); - const [reloaded] = await tx - .select() - .from(schema.contentDatabaseItemKeyClaims) - .where( - and( - eq( - schema.contentDatabaseItemKeyClaims.databaseId, - databaseId, - ), - eq( - schema.contentDatabaseItemKeyClaims.propertyId, - keyPropertyId, - ), - eq( - schema.contentDatabaseItemKeyClaims.keyValueJson, - keyValueJson, - ), - ), - ); - if ( - !reloaded || - reloaded.itemId !== candidate.itemId || - reloaded.documentId !== candidate.documentId - ) { - throw new Error( - "Stable key was claimed by a different row; reconcile before upserting.", - ); - } - identity = candidate; - } - - if (!identity) { - await tx - .insert(schema.contentDatabaseItemKeyClaims) - .values({ - id: nanoid(), - ownerEmail: database.ownerEmail, - orgId: database.orgId, - databaseId, - propertyId: keyPropertyId, - keyValueJson, - ...proposed, - createdAt: now, - updatedAt: now, - }) - .onConflictDoNothing(); - const [reloaded] = await tx - .select() - .from(schema.contentDatabaseItemKeyClaims) - .where( - and( - eq( - schema.contentDatabaseItemKeyClaims.databaseId, - databaseId, - ), - eq( - schema.contentDatabaseItemKeyClaims.propertyId, - keyPropertyId, - ), - eq( - schema.contentDatabaseItemKeyClaims.keyValueJson, - keyValueJson, - ), - ), - ); - if (!reloaded) - throw new Error("Stable key claim could not be read back."); - identity = { - itemId: reloaded.itemId, - documentId: reloaded.documentId, - }; - if ( - identity.itemId === proposed.itemId && - identity.documentId === proposed.documentId - ) { - const [maxDoc] = await tx - .select({ max: sql`COALESCE(MAX(position), -1)` }) - .from(schema.documents) - .where( - and( - eq(schema.documents.ownerEmail, database.ownerEmail), - eq(schema.documents.parentId, database.documentId), - ), - ); - const [maxItem] = await tx - .select({ max: sql`COALESCE(MAX(position), -1)` }) - .from(schema.contentDatabaseItems) - .where( - eq(schema.contentDatabaseItems.databaseId, databaseId), - ); - await tx.insert(schema.documents).values({ - id: proposed.documentId, - spaceId: databaseSpaceId, - ownerEmail: database.ownerEmail, - orgId: database.orgId, - parentId: database.documentId, - title: title?.trim() ?? "", - content: body ?? "", - icon: null, - position: (maxDoc?.max ?? -1) + 1, - isFavorite: 0, - hideFromSearch: databaseDocument.hideFromSearch ?? 0, - visibility: databaseDocument.visibility ?? "private", - createdAt: now, - updatedAt: now, - }); - await tx.insert(schema.contentDatabaseItems).values({ - id: proposed.itemId, - ownerEmail: database.ownerEmail, - orgId: database.orgId, - databaseId, - documentId: proposed.documentId, - position: (maxItem?.max ?? -1) + 1, - createdAt: now, - updatedAt: now, - }); - await tx.insert(schema.documentPropertyValues).values( - [...values.entries()].map(([propertyId, valueJson]) => ({ - id: nanoid(), - ownerEmail: database.ownerEmail, - documentId: proposed.documentId, - propertyId, - valueJson, - createdAt: now, - updatedAt: now, - })), - ); - if (inheritedShares.length > 0) - await tx.insert(schema.documentShares).values( - inheritedShares.map((share) => ({ - id: nanoid(), - resourceId: proposed.documentId, - principalType: share.principalType, - principalId: share.principalId, - role: share.role, - createdBy: getRequestUserEmail() ?? database.ownerEmail, - createdAt: now, - })), - ); - await ensureDocumentFilesMembership( - tx, - proposed.documentId, - now, - ); - return { status: "created" as const, ...identity }; - } - } - - // Serialize every stable-key update for this canonical row at the - // database layer. The in-process position lock cannot protect two - // serverless/PostgreSQL workers, while this no-op UPDATE takes the - // membership row lock until the surrounding transaction commits. - // Re-read property values only after acquiring it so two workers - // cannot both observe a missing value and insert duplicates. - await tx - .update(schema.contentDatabaseItems) - .set({ - updatedAt: sql`${schema.contentDatabaseItems.updatedAt}`, - }) - .where( - and( - eq(schema.contentDatabaseItems.id, identity.itemId), - eq(schema.contentDatabaseItems.databaseId, databaseId), - eq( - schema.contentDatabaseItems.documentId, - identity.documentId, - ), - ), - ); - const [claimedMembership] = await tx - .select({ - itemId: schema.contentDatabaseItems.id, - documentId: schema.contentDatabaseItems.documentId, - }) - .from(schema.contentDatabaseItems) - .where( - and( - eq(schema.contentDatabaseItems.id, identity.itemId), - eq(schema.contentDatabaseItems.databaseId, databaseId), - eq( - schema.contentDatabaseItems.documentId, - identity.documentId, - ), - ), - ); - if (!claimedMembership) { - throw new Error( - "Stable key claim does not resolve to a row in this database.", - ); - } - await assertAccess("document", identity.documentId, "editor"); - // Serialize full-document writes with SQL-backed editor saves. The - // Content editor reconciles genuinely newer SQL snapshots into the - // live Y.Doc; taking this row lock and advancing updatedAt - // monotonically prevents a stale open editor from later winning. - const [document] = await tx - .update(schema.documents) - .set({ updatedAt: sql`${schema.documents.updatedAt}` }) - .where( - and( - eq(schema.documents.id, identity.documentId), - isNull(schema.documents.trashedAt), - ), - ) - .returning(); - if (!document || document.trashedAt) - throw new Error( - "Stable key claim does not resolve to an active document.", - ); - const existingValues = await tx - .select() - .from(schema.documentPropertyValues) - .where( - and( - eq( - schema.documentPropertyValues.documentId, - identity.documentId, - ), - inArray(schema.documentPropertyValues.propertyId, [ - ...values.keys(), - ]), - ), - ); - const existingByProperty = new Map( - existingValues.map((value) => [value.propertyId, value]), - ); - if (existingValues.length !== existingByProperty.size) - throw new Error( - "Target row has duplicate property values; reconcile before upserting.", - ); - const changedValues = [...values.entries()].filter( - ([propertyId, valueJson]) => - existingByProperty.get(propertyId)?.valueJson !== valueJson, - ); - const documentChanged = - (title !== undefined && document.title !== title.trim()) || - (body !== undefined && document.content !== body); - if (!documentChanged && changedValues.length === 0) - return { status: "unchanged" as const, ...identity }; - if (documentChanged) { - const mutationTime = new Date().toISOString(); - const updatedAt = - mutationTime > document.updatedAt - ? mutationTime - : new Date( - new Date(document.updatedAt).getTime() + 1, - ).toISOString(); - await tx - .update(schema.documents) - .set({ - ...(title !== undefined ? { title: title.trim() } : {}), - ...(body !== undefined ? { content: body } : {}), - updatedAt, - }) - .where(eq(schema.documents.id, identity.documentId)); - } - for (const [propertyId, valueJson] of changedValues) { - const existing = existingByProperty.get(propertyId); - if (existing) - await tx - .update(schema.documentPropertyValues) - .set({ valueJson, updatedAt: now }) - .where(eq(schema.documentPropertyValues.id, existing.id)); - else - await tx.insert(schema.documentPropertyValues).values({ - id: nanoid(), - ownerEmail: database.ownerEmail, - documentId: identity.documentId, - propertyId, - valueJson, - createdAt: now, - updatedAt: now, - }); - } - await tx - .delete(schema.contentDatabaseItemKeyClaims) - .where( - and( - eq( - schema.contentDatabaseItemKeyClaims.databaseId, - databaseId, - ), - eq( - schema.contentDatabaseItemKeyClaims.propertyId, - keyPropertyId, - ), - eq( - schema.contentDatabaseItemKeyClaims.documentId, - identity.documentId, - ), - ne( - schema.contentDatabaseItemKeyClaims.keyValueJson, - keyValueJson, - ), - ), - ); - return { status: "updated" as const, ...identity }; - }), - ), - ); - - await writeAppState("refresh-signal", { ts: Date.now() }).catch(() => {}); - const readback = await getContentDatabaseResponse(databaseId, { - limit: 2, - offset: 0, - documentIds: [result.documentId], - }); - const readbackItem = readback.items[0]; - const readbackPropertiesById = new Map( - readbackItem?.properties.map((property) => [ - property.definition.id, - property, - ]) ?? [], - ); - const readbackValuesMatch = [...values.entries()].every( - ([propertyId, expectedValueJson]) => { - const definition = definitionsById.get(propertyId); - const property = readbackPropertiesById.get(propertyId); - return ( - definition !== undefined && - property !== undefined && - normalizedValueJson( - definition.type as DocumentPropertyType, - property.value, - ) === expectedValueJson - ); - }, - ); - const verifiedDocument = await getDocument.run({ - id: result.documentId, - databaseId, - databaseDocumentId: database.documentId, - }); - if ( - readback.items.length !== 1 || - readbackItem?.id !== result.itemId || - verifiedDocument?.id !== result.documentId || - !readbackValuesMatch || - (title !== undefined && readbackItem?.document.title !== title.trim()) || - (title !== undefined && verifiedDocument.title !== title.trim()) || - (body !== undefined && verifiedDocument.content !== body) - ) - throw new Error( - "Stable key upsert could not verify its exact requested row readback.", - ); + "Create or sparsely update one Content database row by that database's explicitly configured natural key. Requires schema and row compare-and-swap revisions and returns a verified idempotent receipt.", + schema, + audit: { + recordInputs: false, + target: (args) => ({ + type: "content-database", + id: args.target.databaseId, + visibility: "private", + }), + summary: (_args, result) => { + const receipt = (result as ContentDatabaseRowMutationResult | null) + ?.receipt; + return receipt + ? `${receipt.outcome === "created" ? "Created" : receipt.outcome === "updated" ? "Updated" : "Checked"} natural-key row ${receipt.row.itemId}` + : "Upserted Content database row by natural key"; + }, + }, + run: upsertDatabaseRow, + link: ({ result }) => { + const documentId = (result as ContentDatabaseRowMutationResult | null) + ?.receipt.row.documentId; + if (!documentId) return null; return { - ...result, - databaseId, - keyPropertyId, - keyValue, - readback: { items: readback.items, pagination: readback.pagination }, + url: buildDeepLink({ + app: "content", + view: "editor", + params: { documentId }, + }), + label: "Open database row", + view: "editor", }; - }, - ); }, }); diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 5efd224ec8..07aba79db2 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -1593,6 +1593,11 @@ function DatabaseTable({ ) { if (!databaseId) return null; if (isWorkspaceCatalog) return null; + const mutationContract = data?.mutationContract; + if (!mutationContract) { + toast.error(dbText("failedToCreateRow")); + return null; + } const propertyValues = { ...databasePropertyValuesForNewItem(filters, properties, filterMode), ...propertyValueOverrides, @@ -1600,7 +1605,9 @@ function DatabaseTable({ let response; try { response = await addItem.mutateAsync({ - databaseId, + target: mutationContract.target, + expectedSchemaRevision: mutationContract.schemaRevision, + idempotencyKey: crypto.randomUUID(), title, propertyValues: Object.keys(propertyValues).length > 0 ? propertyValues : undefined, @@ -1612,12 +1619,7 @@ function DatabaseTable({ }); return null; } - const createdItem = databaseCreatedItemForImmediatePreview(response, { - databaseId, - parentDocument: document, - title, - propertyValues, - }); + const createdItem = response.createdItem ?? null; const needsPreview = !!createdItem && databaseCreatedItemNeedsPreview(items, createdItem, options); diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index b7c7ab7bf0..24884eaa25 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -12,6 +12,7 @@ import type { CancelPreparedBuilderSourceUpdateResponse, ChangeContentDatabaseSourceRoleRequest, ContentDatabaseResponse, + ContentDatabaseRowMutationResult, ContentDatabaseSourceAttachmentAck, ContentDatabaseSourceAttachmentResult, ContentDatabaseItemsPageResponse, @@ -819,40 +820,40 @@ export function useTrashedContentDatabases() { export function useAddDatabaseItem(documentId: string) { const queryClient = useQueryClient(); - return useActionMutation( - "add-database-item", - { - skipActionQueryInvalidation: true, - onSuccess: (data) => { - if (data.createdItem) { - queryClient.setQueriesData( - { - queryKey: ["action", "get-content-database"], - predicate: (query) => { - if ( - !isContentDatabaseQueryForDocument(query.queryKey, documentId) - ) { - return false; - } - const params = query.queryKey[2] as { - tableQuery?: unknown; - }; - return params.tableQuery === undefined; - }, + return useActionMutation< + ContentDatabaseRowMutationResult, + AddDatabaseItemRequest + >("add-database-item", { + skipActionQueryInvalidation: true, + onSuccess: (data) => { + if (data.createdItem) { + queryClient.setQueriesData( + { + queryKey: ["action", "get-content-database"], + predicate: (query) => { + if ( + !isContentDatabaseQueryForDocument(query.queryKey, documentId) + ) { + return false; + } + const params = query.queryKey[2] as { + tableQuery?: unknown; + }; + return params.tableQuery === undefined; }, - (current) => - applyOptimisticItemToContentDatabase(current, data.createdItem!), - ); - } - queryClient.invalidateQueries({ - queryKey: contentDatabaseQueryKey(documentId), - }); - queryClient.invalidateQueries({ - queryKey: ["action", "list-documents"], - }); - }, + }, + (current) => + applyOptimisticItemToContentDatabase(current, data.createdItem!), + ); + } + queryClient.invalidateQueries({ + queryKey: contentDatabaseQueryKey(documentId), + }); + queryClient.invalidateQueries({ + queryKey: ["action", "list-documents"], + }); }, - ); + }); } export function useSubmitContentDatabaseForm(documentId: string) { diff --git a/templates/content/changelog/2026-08-10-database-row-actions-now-validate-exact-schemas-and-safe-ret.md b/templates/content/changelog/2026-08-10-database-row-actions-now-validate-exact-schemas-and-safe-ret.md new file mode 100644 index 0000000000..00f6d22552 --- /dev/null +++ b/templates/content/changelog/2026-08-10-database-row-actions-now-validate-exact-schemas-and-safe-ret.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-08-10 +--- + +Database row actions now validate exact schemas and safe retries before changing data. diff --git a/templates/content/parity/matrix.md b/templates/content/parity/matrix.md index 30543ed59f..4a06a8e701 100644 --- a/templates/content/parity/matrix.md +++ b/templates/content/parity/matrix.md @@ -9,7 +9,7 @@ This generated matrix tracks whether high-value Content UI operations use the sa | 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`, `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` | `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`, `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/migrate-content-database-rows.db.test.ts`, `parity/__tests__/database-row-batch-reliability.test.ts` | `database-bulk-row-reliability` | - | +| 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 | - | - | - | diff --git a/templates/content/parity/matrix.ts b/templates/content/parity/matrix.ts index 307d8bdb93..b96f37f5a4 100644 --- a/templates/content/parity/matrix.ts +++ b/templates/content/parity/matrix.ts @@ -323,6 +323,7 @@ export const parityMatrix: ParityRow[] = [ status: "action-backed", actions: [ "add-database-item", + "update-database-item", "upsert-database-item-by-key", "remove-database-items", "duplicate-database-items", @@ -338,6 +339,7 @@ export const parityMatrix: ParityRow[] = [ followUpPR: null, coverageRefs: [ "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", ], diff --git a/templates/content/server/agent-card.test.ts b/templates/content/server/agent-card.test.ts index 6c4432906b..746e12c533 100644 --- a/templates/content/server/agent-card.test.ts +++ b/templates/content/server/agent-card.test.ts @@ -19,6 +19,8 @@ const REQUIRED_CONTENT_ACTIONS = [ "update-document", "move-document", "navigate", + "add-database-item", + "update-database-item", "upsert-database-item-by-key", ]; diff --git a/templates/content/server/db/schema.ts b/templates/content/server/db/schema.ts index ba4ab76113..7f5ffbd181 100644 --- a/templates/content/server/db/schema.ts +++ b/templates/content/server/db/schema.ts @@ -234,6 +234,7 @@ export const contentDatabases = table( ownerBlockId: text("owner_block_id"), title: text("title").notNull().default("Untitled database"), systemRole: text("system_role"), + naturalKeyPropertyId: text("natural_key_property_id"), viewConfigJson: text("view_config_json").notNull().default("{}"), filesSystemPropertiesSeeded: integer("files_system_properties_seeded") .notNull() @@ -287,9 +288,9 @@ export const contentDatabaseItems = table( ], ); -// Opt-in stable-key claims are the durable concurrency fence for the generic -// database-row upsert action. They intentionally do not constrain ordinary -// property editing or change add-database-item behavior. +// Opt-in stable-key claims are the durable concurrency fence for configured +// natural-key upserts. Ordinary property editing stays independent until a +// text property is explicitly selected as the database's natural key. export const contentDatabaseItemKeyClaims = table( "content_database_item_key_claims", { @@ -494,6 +495,41 @@ export const contentDatabaseMigrationReceipts = table( ], ); +export const contentDatabaseRowMutationReceipts = table( + "content_database_row_mutation_receipts", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + spaceId: text("space_id").notNull(), + databaseId: text("database_id").notNull(), + databaseDocumentId: text("database_document_id").notNull(), + operation: text("operation").notNull(), + itemId: text("item_id").notNull(), + documentId: text("document_id").notNull(), + idempotencyKey: text("idempotency_key").notNull(), + payloadDigest: text("payload_digest").notNull(), + schemaRevision: text("schema_revision").notNull(), + preRowRevision: text("pre_row_revision"), + postRowRevision: text("post_row_revision").notNull(), + resultJson: text("result_json").notNull().default("{}"), + createdAt: text("created_at").notNull().default(now()), + updatedAt: text("updated_at").notNull().default(now()), + }, + (receipt) => [ + uniqueIndex( + "content_database_row_mutation_receipts_database_key_unique", + ).on(receipt.databaseId, receipt.idempotencyKey), + index("content_database_row_mutation_receipts_owner_database_idx").on( + receipt.ownerEmail, + receipt.databaseId, + ), + index("content_database_row_mutation_receipts_document_idx").on( + receipt.documentId, + ), + ], +); + export const documentPropertyValues = table("document_property_values", { id: text("id").primaryKey(), ownerEmail: text("owner_email").notNull().default("local@localhost"), diff --git a/templates/content/server/plugins/db.ts b/templates/content/server/plugins/db.ts index 1e1ba634d4..c27aeba5f8 100644 --- a/templates/content/server/plugins/db.ts +++ b/templates/content/server/plugins/db.ts @@ -956,6 +956,36 @@ export const runContentMigrations = runMigrations( CREATE INDEX IF NOT EXISTS content_database_migration_receipts_owner_database_idx ON content_database_migration_receipts (owner_email, database_id)`, }, + { + version: 81, + name: "content-database-row-mutation-contract", + sql: `ALTER TABLE content_databases ADD COLUMN natural_key_property_id TEXT; + CREATE TABLE IF NOT EXISTS content_database_row_mutation_receipts ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + space_id TEXT NOT NULL, + database_id TEXT NOT NULL, + database_document_id TEXT NOT NULL, + operation TEXT NOT NULL, + item_id TEXT NOT NULL, + document_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + payload_digest TEXT NOT NULL, + schema_revision TEXT NOT NULL, + pre_row_revision TEXT, + post_row_revision TEXT NOT NULL, + result_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE UNIQUE INDEX IF NOT EXISTS content_database_row_mutation_receipts_database_key_unique + ON content_database_row_mutation_receipts (database_id, idempotency_key); + CREATE INDEX IF NOT EXISTS content_database_row_mutation_receipts_owner_database_idx + ON content_database_row_mutation_receipts (owner_email, database_id); + CREATE INDEX IF NOT EXISTS content_database_row_mutation_receipts_document_idx + ON content_database_row_mutation_receipts (document_id)`, + }, ], { table: "content_migrations" }, ); diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index e5a5f0fcb8..30263a14ad 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -207,6 +207,7 @@ export interface ConfigureDocumentPropertyRequest { description?: string; visibility?: DocumentPropertyVisibility; options?: DocumentPropertyOptions; + naturalKey?: boolean; } export interface SetDocumentPropertyRequest { @@ -239,8 +240,10 @@ export interface ReorderDocumentPropertyRequest { export interface ContentDatabase { id: string; documentId: string; + spaceId?: string | null; title: string; systemRole?: string | null; + naturalKeyPropertyId?: string | null; description?: string; viewConfig: ContentDatabaseViewConfig; createdAt: string; @@ -450,6 +453,62 @@ export interface ContentDatabaseItem { // a secondary source contributes on top of it. Absent for non-federated rows. canonicalKey?: string | null; sourceOverlays?: ContentDatabaseSourceOverlay[]; + rowRevision?: string; +} + +export interface ContentDatabaseMutationTarget { + authorityScope: + | { kind: "personal"; id: string } + | { kind: "organization"; id: string }; + spaceId: string; + databaseId: string; + databaseDocumentId: string; +} + +export interface ContentDatabaseMutationContract { + target: ContentDatabaseMutationTarget; + schemaRevision: string; + naturalKeyPropertyId: string | null; + properties: Array<{ + id: string; + name: string; + type: DocumentPropertyType; + writable: boolean; + sourceManaged: boolean; + acceptedShape: string | null; + options: DocumentPropertyOptions; + }>; +} + +export interface ContentDatabaseRowMutationReceipt { + receiptId: string; + operation: "create" | "update" | "upsert"; + outcome: "created" | "updated" | "unchanged"; + target: ContentDatabaseMutationTarget; + schemaRevision: string; + row: { + itemId: string; + documentId: string; + urlPath: string; + rowRevision: string; + }; + affected: { title: boolean; propertyIds: string[] }; + idempotency: { + key: string; + result: "applied" | "replayed"; + payloadDigest: string; + }; + revisions: { before: string | null; after: string }; + readback: { + verified: true; + title: string; + propertyValues: Record; + }; +} + +export interface ContentDatabaseRowMutationResult { + receipt: ContentDatabaseRowMutationReceipt; + createdItem?: ContentDatabaseItem; } // A secondary source's read-only contribution to a federated row, matched on the @@ -809,6 +868,7 @@ export interface ContentDatabaseResponse { removedCount?: number; timings?: BuilderActionTiming[]; tableQueryMode?: "server" | "client-required"; + mutationContract?: ContentDatabaseMutationContract; /** Client-only optimistic state while real provider rows are being attached. */ attachPreview?: { sourceTable: string; @@ -873,9 +933,22 @@ export interface CreateInlineDatabaseResponse { } export interface AddDatabaseItemRequest { - databaseId: string; + target: ContentDatabaseMutationTarget; + expectedSchemaRevision: string; + idempotencyKey: string; title?: string; - propertyValues?: Record; + propertyValues?: Record; +} + +export interface UpdateDatabaseItemRequest extends AddDatabaseItemRequest { + itemId: string; + documentId: string; + expectedRowRevision: string; +} + +export interface UpsertDatabaseItemByKeyRequest extends AddDatabaseItemRequest { + keyValue: string; + expectedRowRevision: string | null; } export interface SubmitContentDatabaseFormRequest { From fd4aa9e4e2558e369b8adc2c8ff8d62eead3c74c Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:04:10 -0400 Subject: [PATCH 03/16] fix: repair Content row mutation CI coverage --- packages/core/src/index.browser.ts | 3 +++ .../DatabaseView.error-toasts.test.tsx | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/core/src/index.browser.ts b/packages/core/src/index.browser.ts index efdc5ae362..ccd7fe18da 100644 --- a/packages/core/src/index.browser.ts +++ b/packages/core/src/index.browser.ts @@ -89,6 +89,9 @@ export { parseArgs, camelCaseArgs } from "./scripts/parse-args.js"; // defineAction — used by template actions, no Node.js deps export { defineAction, + ActionContractError, + isActionContractError, + type ActionContractErrorOptions, AgentActionStopError, isAgentActionStopError, type ActionHttpConfig, diff --git a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx index 82a6d699e5..bfb52ffc8b 100644 --- a/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.error-toasts.test.tsx @@ -209,6 +209,17 @@ const databaseResponse: ContentDatabaseResponse = { source: null, sources: [], pagination: databasePagination, + mutationContract: { + target: { + authorityScope: { kind: "personal", id: "owner@example.com" }, + spaceId: "space-1", + databaseId: "database-1", + databaseDocumentId: "document-1", + }, + schemaRevision: "sha256:test-schema-revision", + naturalKeyPropertyId: null, + properties: [], + }, }; const fakeDocument = { @@ -374,6 +385,14 @@ describe("DatabaseView UI regressions", () => { }); expect(addItemMutation.mutateAsync).toHaveBeenCalledTimes(1); + expect(addItemMutation.mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + target: databaseResponse.mutationContract!.target, + expectedSchemaRevision: + databaseResponse.mutationContract!.schemaRevision, + idempotencyKey: expect.any(String), + }), + ); expect(toastErrorMock).toHaveBeenCalledTimes(1); expect(toastErrorMock).toHaveBeenCalledWith( failedToCreateRow, From 04d0004b5bb2bd27f2737b68cf4a82db8528ffc5 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:05:44 -0400 Subject: [PATCH 04/16] feat(content): add exact database block actions --- .../document-editing/references/databases.md | 13 + templates/content/AGENTS.md | 2 + .../content/actions/_blocks-field-identity.ts | 2 + .../actions/_database-block-actions.ts | 786 ++++++++++++++++++ .../content/actions/_database-row-mutation.ts | 14 +- .../content-database-block-actions.db.test.ts | 438 ++++++++++ .../content/actions/delete-document.test.ts | 8 + .../actions/list-content-database-blocks.ts | 31 + .../actions/mutate-content-database-block.ts | 44 + ...ert-update-upsert-delete-and-reorder-on.md | 6 + .../capabilities/content.object.block.md | 8 +- .../content.object.blocks-field.md | 3 +- templates/content/parity/matrix.md | 58 +- templates/content/parity/matrix.ts | 5 +- templates/content/server/agent-card.test.ts | 2 + .../content/shared/blocks-field-identity.ts | 136 ++- .../content/shared/database-block-actions.ts | 82 ++ .../shared/database-block-mutations.spec.ts | 281 +++++++ .../shared/database-block-mutations.ts | 296 +++++++ 19 files changed, 2138 insertions(+), 77 deletions(-) create mode 100644 templates/content/actions/_database-block-actions.ts create mode 100644 templates/content/actions/content-database-block-actions.db.test.ts create mode 100644 templates/content/actions/list-content-database-blocks.ts create mode 100644 templates/content/actions/mutate-content-database-block.ts create mode 100644 templates/content/changelog/2026-08-10-agents-can-safely-insert-update-upsert-delete-and-reorder-on.md create mode 100644 templates/content/shared/database-block-actions.ts create mode 100644 templates/content/shared/database-block-mutations.spec.ts create mode 100644 templates/content/shared/database-block-mutations.ts 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 a01c562163..84fa1cc28f 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -85,6 +85,8 @@ ladder. | `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 ea6e50850a..2f8f104bfb 100644 --- a/templates/content/actions/_blocks-field-identity.ts +++ b/templates/content/actions/_blocks-field-identity.ts @@ -170,6 +170,7 @@ export async function persistBlocksFieldIdentity(args: { previousMarkdown: string; markdown: string; expectedRevision?: number; + preferredIdsByPath?: Readonly>; now: string; }): Promise { const fieldId = blocksFieldId(args.documentId, args.propertyId); @@ -211,6 +212,7 @@ export async function persistBlocksFieldIdentity(args: { previous, markdown: args.markdown, createId: () => `block_${nanoid(16)}`, + preferredIdsByPath: args.preferredIdsByPath, }); if (next.blocks.length > 0) { diff --git a/templates/content/actions/_database-block-actions.ts b/templates/content/actions/_database-block-actions.ts new file mode 100644 index 0000000000..ee6d3e2737 --- /dev/null +++ b/templates/content/actions/_database-block-actions.ts @@ -0,0 +1,786 @@ +import { ActionContractError } from "@agent-native/core"; +import { assertAccess } from "@agent-native/core/sharing"; +import { and, eq, isNull } 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 { + 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), +}); + +export const mutateDatabaseBlockSchema = 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 BlockTarget = z.infer; +type MutationInput = z.infer; + +interface LoadedField { + context: MutationContext; + row: RowSnapshot; + markdown: string; + identity: BlocksFieldIdentity; + ownerEmail: string; + storageTarget: BlocksStorageTarget; +} + +function contractError( + errorCode: string, + message: string, + details?: Record, + statusCode = 409, +): never { + throw new ActionContractError(message, { errorCode, details, statusCode }); +} + +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; + 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, + ), + ), + ); + 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, + }; +} + +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, + }, + }; + } + 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") { + await db + .update(schema.documents) + .set({ content: markdown, updatedAt: now }) + .where( + and( + eq(schema.documents.id, target.rowDocumentId), + isNull(schema.documents.trashedAt), + ), + ); + return; + } + await db + .insert(schema.documentBlockFieldContents) + .values({ + id: nanoid(), + ownerEmail: loaded.ownerEmail, + documentId: target.rowDocumentId, + propertyId: target.propertyId, + content: markdown, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [ + schema.documentBlockFieldContents.documentId, + schema.documentBlockFieldContents.propertyId, + ], + set: { content: markdown, updatedAt: now }, + }); +} + +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 }, + ); + } + const verified = await verifyResult(parsed, db); + return { + receipt: { + ...verified.receipt, + idempotency: { ...verified.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 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, + }); + } 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); + await persistBlocksFieldIdentity({ + db: tx, + ownerEmail: loaded.ownerEmail, + documentId: input.target.rowDocumentId, + propertyId: input.target.propertyId, + previousMarkdown: loaded.markdown, + markdown: changed.markdown, + expectedRevision: input.expectedFieldRevision, + preferredIdsByPath: changed.preferredIdsByPath, + now, + }); + postIdentity = await readBlocksFieldIdentity({ + db: tx, + documentId: input.target.rowDocumentId, + propertyId: input.target.propertyId, + markdown: changed.markdown, + }); + 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 8774e56395..ae316766b0 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( @@ -884,7 +884,7 @@ async function verifyCommittedResult(result: ContentDatabaseRowMutationResult) { return result; } -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..2ad4ceb6cd --- /dev/null +++ b/templates/content/actions/content-database-block-actions.db.test.ts @@ -0,0 +1,438 @@ +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runWithRequestContext } from "@agent-native/core/server"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// 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; + +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; + 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("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" }, + }), + ); + expect(existingUpsert.receipt.affected.blockIds).toContain(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); + await expect( + asOwner(() => + mutateBlock.run({ + target: state.target, + expectedSchemaRevision: deleted.receipt.schemaRevision, + expectedRowRevision: deleted.receipt.revisions.row.after, + expectedFieldRevision: deleted.receipt.revisions.field.after, + idempotencyKey: "restore-tombstone", + operation: "upsert", + blockId: alpha!.id, + block: { kind: "paragraph", nfm: "Not a restore" }, + position: { placement: "start" }, + }), + ), + ).rejects.toMatchObject({ errorCode: "BLOCK_ID_TOMBSTONED" }); + }); + + 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); + + 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"); + }); +}); diff --git a/templates/content/actions/delete-document.test.ts b/templates/content/actions/delete-document.test.ts index 4248a4a96d..cce499e347 100644 --- a/templates/content/actions/delete-document.test.ts +++ b/templates/content/actions/delete-document.test.ts @@ -88,6 +88,14 @@ const { schema } = vi.hoisted(() => ({ propertyId: "documentBlockFieldContents.propertyId", documentId: "documentBlockFieldContents.documentId", }, + documentBlockFields: { + id: "documentBlockFields.id", + propertyId: "documentBlockFields.propertyId", + documentId: "documentBlockFields.documentId", + }, + documentBlocks: { + fieldId: "documentBlocks.fieldId", + }, documentSyncLinks: { documentId: "documentSyncLinks.documentId", ownerEmail: "documentSyncLinks.ownerEmail", 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..6aef986321 --- /dev/null +++ b/templates/content/actions/mutate-content-database-block.ts @@ -0,0 +1,44 @@ +import { defineAction } from "@agent-native/core"; +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: mutateDatabaseBlock, + 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 4a06a8e701..b039965f66 100644 --- a/templates/content/parity/matrix.md +++ b/templates/content/parity/matrix.md @@ -2,32 +2,32 @@ 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`, `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` | `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.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`, `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` | `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.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 b96f37f5a4..6dbd658746 100644 --- a/templates/content/parity/matrix.ts +++ b/templates/content/parity/matrix.ts @@ -319,12 +319,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", @@ -341,6 +343,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 746e12c533..9146b55d1f 100644 --- a/templates/content/server/agent-card.test.ts +++ b/templates/content/server/agent-card.test.ts @@ -22,6 +22,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; diff --git a/templates/content/shared/blocks-field-identity.ts b/templates/content/shared/blocks-field-identity.ts index f48a2bb746..7911e408fd 100644 --- a/templates/content/shared/blocks-field-identity.ts +++ b/templates/content/shared/blocks-field-identity.ts @@ -2,6 +2,83 @@ import { docToNfm, nfmToDoc, type PMNode } from "./nfm.js"; export const BLOCKS_FIELD_IDENTITY_VERSION = 1; +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 { @@ -44,7 +121,7 @@ export interface StoredBlocksFieldIdentity { blocks: StoredBlocksFieldBlock[]; } -interface BlockSnapshot { +export interface BlocksFieldBlockSnapshot { path: string; parentPath: string | null; kind: string; @@ -55,34 +132,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", @@ -131,8 +181,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) { @@ -170,7 +222,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}`, @@ -219,7 +271,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) => { @@ -300,9 +352,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", ); @@ -312,6 +365,7 @@ 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 previousExact = uniqueIndexByKey( previousLive, @@ -442,9 +496,17 @@ export function reconcileBlocksFieldIdentity(args: { if (previous) recoveredIds.add(previous.id); } } - let id = previous?.id ?? snapshot.preferredId ?? args.createId(); - while (usedIds.has(id) && id !== previous?.id) id = args.createId(); + const explicitId = args.preferredIdsByPath?.[snapshot.path]; + let id = + explicitId ?? previous?.id ?? snapshot.preferredId ?? args.createId(); + while ( + assignedNextIds.has(id) || + (!explicitId && usedIds.has(id) && id !== previous?.id) + ) { + id = args.createId(); + } usedIds.add(id); + assignedNextIds.add(id); idByPath.set(snapshot.path, id); return { id, @@ -508,7 +570,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..f08eef3ca8 --- /dev/null +++ b/templates/content/shared/database-block-mutations.spec.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from "vitest"; + +import { + BLOCKS_FIELD_BLOCK_KINDS, + BLOCKS_FIELD_OPERATION_CAPABILITIES, + 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("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("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)( + "accepts canonical typed NFM for the live %s mutation contract", + (kind, markdown) => { + const before = identity(markdown); + const block = before.blocks.find((candidate) => candidate.kind === kind); + expect( + block, + `${kind} must be indexed by the identity contract`, + ).toBeTruthy(); + expect(() => + mutateBlocksFieldDocument({ + markdown, + identity: before, + mutation: { + operation: "update", + blockId: block!.id, + block: { kind, nfm: markdown }, + }, + }), + ).not.toThrow(); + }, + ); +}); diff --git a/templates/content/shared/database-block-mutations.ts b/templates/content/shared/database-block-mutations.ts new file mode 100644 index 0000000000..82ed1ee5c1 --- /dev/null +++ b/templates/content/shared/database-block-mutations.ts @@ -0,0 +1,296 @@ +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 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" + : ![ + "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 preferredIds( + doc: PMDoc, + idByNode: Map, + requestedNode?: PMNode, + requestedId?: string, +) { + return Object.fromEntries( + collectNodeRefs(doc).flatMap((ref) => { + const id = + idByNode.get(ref.node) ?? + (ref.node === requestedNode ? requestedId : undefined); + return id ? [[ref.path, id]] : []; + }), + ); +} + +export function mutateBlocksFieldDocument(args: { + markdown: string; + identity: BlocksFieldIdentity; + mutation: BlockDocumentMutation; + insertedBlockId?: 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; + idByNode.set(requestedNode, args.mutation.blockId); + } 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") { + const position = args.mutation.position; + if ( + "anchorBlockId" in position && + position.anchorBlockId === args.mutation.blockId + ) { + throw new Error("A block cannot be reordered relative to itself."); + } + const target = destination(position, byId, doc); + if (target.parentPath !== current.parentPath) { + throw new Error("Cross-parent block reorder is not supported."); + } + current.nodes.splice(current.nodeIndex, 1); + const targetIndex = + "anchorBlockId" in position + ? (() => { + const anchor = byId.get(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 + (position.placement === "after" ? 1 : 0); + })() + : position.placement === "start" + ? 0 + : target.nodes.length; + target.nodes.splice(targetIndex, 0, current.node); + } + } + + const markdown = docToNfm(doc); + return { + markdown, + preferredIdsByPath: preferredIds( + doc, + idByNode, + requestedNode, + args.insertedBlockId, + ), + requestedBlockId, + deletedCandidateIds, + changed: markdown !== before, + }; +} From fc634ac2132d2f94dcb8dc686986c89ec46700e6 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:06:24 -0400 Subject: [PATCH 05/16] fix(content): keep block identity portable --- templates/content/actions/delete-document.test.ts | 8 ++++++++ templates/content/server/plugins/db.ts | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/templates/content/actions/delete-document.test.ts b/templates/content/actions/delete-document.test.ts index 4248a4a96d..4b91ebf5dd 100644 --- a/templates/content/actions/delete-document.test.ts +++ b/templates/content/actions/delete-document.test.ts @@ -88,6 +88,14 @@ const { schema } = vi.hoisted(() => ({ propertyId: "documentBlockFieldContents.propertyId", documentId: "documentBlockFieldContents.documentId", }, + documentBlockFields: { + id: "documentBlockFields.id", + documentId: "documentBlockFields.documentId", + propertyId: "documentBlockFields.propertyId", + }, + documentBlocks: { + fieldId: "documentBlocks.fieldId", + }, documentSyncLinks: { documentId: "documentSyncLinks.documentId", ownerEmail: "documentSyncLinks.ownerEmail", diff --git a/templates/content/server/plugins/db.ts b/templates/content/server/plugins/db.ts index bbb6b3113f..d19c6734f9 100644 --- a/templates/content/server/plugins/db.ts +++ b/templates/content/server/plugins/db.ts @@ -999,6 +999,18 @@ export const runContentMigrations = runMigrations( CREATE INDEX IF NOT EXISTS document_blocks_parent_idx ON document_blocks (parent_id)`, }, + // The portable schema maps integer({ mode: "boolean" }) to BOOLEAN on + // Postgres, while the raw INTEGER migration above is adapted to BIGINT. + // Convert the stored column before Drizzle sends boolean values. + { + version: 83, + name: "content-block-addressable-postgres-boolean", + sql: { + postgres: `ALTER TABLE document_blocks ALTER COLUMN addressable DROP DEFAULT; + ALTER TABLE document_blocks ALTER COLUMN addressable TYPE boolean USING (addressable::int != 0); + ALTER TABLE document_blocks ALTER COLUMN addressable SET DEFAULT true`, + }, + }, ], { table: "content_migrations" }, ); From aa4b3cb74cab8ea1a65109a464c02d13c198925f Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:08:33 -0400 Subject: [PATCH 06/16] fix(content): make block migration retry-safe --- templates/content/server/plugins/db.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/content/server/plugins/db.ts b/templates/content/server/plugins/db.ts index d19c6734f9..27d79e254a 100644 --- a/templates/content/server/plugins/db.ts +++ b/templates/content/server/plugins/db.ts @@ -1007,7 +1007,7 @@ export const runContentMigrations = runMigrations( name: "content-block-addressable-postgres-boolean", sql: { postgres: `ALTER TABLE document_blocks ALTER COLUMN addressable DROP DEFAULT; - ALTER TABLE document_blocks ALTER COLUMN addressable TYPE boolean USING (addressable::int != 0); + ALTER TABLE document_blocks ALTER COLUMN addressable TYPE boolean USING addressable::text::boolean; ALTER TABLE document_blocks ALTER COLUMN addressable SET DEFAULT true`, }, }, From 6b764e32e3e3abfac9cf13ae205aa7ba238d51dd Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:11:37 -0400 Subject: [PATCH 07/16] fix(content): harden exact block mutations --- .../actions/_database-block-actions.ts | 20 +++-- .../content-database-block-actions.db.test.ts | 53 ++++++++++- .../content/shared/blocks-field-identity.ts | 21 ++++- .../shared/database-block-mutations.spec.ts | 89 ++++++++++++++++--- .../shared/database-block-mutations.ts | 20 +++-- 5 files changed, 173 insertions(+), 30 deletions(-) diff --git a/templates/content/actions/_database-block-actions.ts b/templates/content/actions/_database-block-actions.ts index ee6d3e2737..b4b32d76af 100644 --- a/templates/content/actions/_database-block-actions.ts +++ b/templates/content/actions/_database-block-actions.ts @@ -455,15 +455,24 @@ async function writeMarkdown( now: string, ) { if (loaded.storageTarget === "document_body") { - await db + 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 db @@ -522,11 +531,10 @@ async function readExistingReceipt( { idempotencyKey }, ); } - const verified = await verifyResult(parsed, db); return { receipt: { - ...verified.receipt, - idempotency: { ...verified.receipt.idempotency, result: "replayed" }, + ...parsed.receipt, + idempotency: { ...parsed.receipt.idempotency, result: "replayed" }, }, }; } @@ -782,5 +790,7 @@ export async function mutateDatabaseBlock( }); }, ); - return verifyResult(result); + return result.receipt.idempotency.result === "replayed" + ? result + : verifyResult(result); } diff --git a/templates/content/actions/content-database-block-actions.db.test.ts b/templates/content/actions/content-database-block-actions.db.test.ts index 2ad4ceb6cd..693fa623a1 100644 --- a/templates/content/actions/content-database-block-actions.db.test.ts +++ b/templates/content/actions/content-database-block-actions.db.test.ts @@ -269,13 +269,31 @@ describe("exact Content database block actions", () => { }), ); 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: deleted.receipt.schemaRevision, - expectedRowRevision: deleted.receipt.revisions.row.after, - expectedFieldRevision: deleted.receipt.revisions.field.after, + expectedSchemaRevision: current.schemaRevision, + expectedRowRevision: current.rowRevision, + expectedFieldRevision: current.fieldRevision, idempotencyKey: "restore-tombstone", operation: "upsert", blockId: alpha!.id, @@ -284,6 +302,10 @@ describe("exact Content database block actions", () => { }), ), ).rejects.toMatchObject({ errorCode: "BLOCK_ID_TOMBSTONED" }); + + const lateReplay = await asOwner(() => mutateBlock.run(insertInput)); + expect(lateReplay.receipt.receiptId).toBe(inserted.receipt.receiptId); + expect(lateReplay.receipt.idempotency.result).toBe("replayed"); }); it("rejects stale row, field, schema, target, access, and unsupported operations without clobbering", async () => { @@ -435,4 +457,29 @@ describe("exact Content database block actions", () => { ); 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"); + }); }); diff --git a/templates/content/shared/blocks-field-identity.ts b/templates/content/shared/blocks-field-identity.ts index 7911e408fd..66e81f4942 100644 --- a/templates/content/shared/blocks-field-identity.ts +++ b/templates/content/shared/blocks-field-identity.ts @@ -367,6 +367,20 @@ export function reconcileBlocksFieldIdentity(args: { 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, (block) => `${block.kind}\0${block.contentHash}`, @@ -486,8 +500,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}`, ); @@ -496,7 +511,9 @@ export function reconcileBlocksFieldIdentity(args: { if (previous) recoveredIds.add(previous.id); } } - const explicitId = args.preferredIdsByPath?.[snapshot.path]; + if (explicitId && usedIds.has(explicitId) && explicitId !== previous?.id) { + throw new Error(`Preferred Block ID is already reserved: ${explicitId}`); + } let id = explicitId ?? previous?.id ?? snapshot.preferredId ?? args.createId(); while ( diff --git a/templates/content/shared/database-block-mutations.spec.ts b/templates/content/shared/database-block-mutations.spec.ts index f08eef3ca8..869f6d1f3d 100644 --- a/templates/content/shared/database-block-mutations.spec.ts +++ b/templates/content/shared/database-block-mutations.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { BLOCKS_FIELD_BLOCK_KINDS, BLOCKS_FIELD_OPERATION_CAPABILITIES, + exposeBlocksFieldIdentity, legacyBlocksFieldIdentity, materializeLegacyBlocksFieldIdentity, reconcileBlocksFieldIdentity, @@ -257,25 +258,87 @@ describe("individual Blocks-field document mutations", () => { }); it.each(fullyMutableKinds)( - "accepts canonical typed NFM for the live %s mutation contract", + "executes every declared individual operation for live %s blocks", (kind, markdown) => { - const before = identity(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(); - expect(() => - mutateBlocksFieldDocument({ - markdown, - identity: before, - mutation: { - operation: "update", - blockId: block!.id, - block: { kind, nfm: markdown }, - }, - }), - ).not.toThrow(); + + 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, + }); + 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 index 82ed1ee5c1..26e49ed5e1 100644 --- a/templates/content/shared/database-block-mutations.ts +++ b/templates/content/shared/database-block-mutations.ts @@ -281,16 +281,22 @@ export function mutateBlocksFieldDocument(args: { } const markdown = docToNfm(doc); + const preferredIdsByPath = preferredIds( + doc, + idByNode, + requestedNode, + args.insertedBlockId, + ); + const identityOrderChanged = collectNodeRefs(doc).some( + (ref, index) => + preferredIdsByPath[ref.path] !== undefined && + preferredIdsByPath[ref.path] !== args.identity.blocks[index]?.id, + ); return { markdown, - preferredIdsByPath: preferredIds( - doc, - idByNode, - requestedNode, - args.insertedBlockId, - ), + preferredIdsByPath, requestedBlockId, deletedCandidateIds, - changed: markdown !== before, + changed: markdown !== before || identityOrderChanged, }; } From dbce4664ea25d05981e8c5d2434e9674df7a2cb6 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:15:00 -0400 Subject: [PATCH 08/16] fix(content): preserve nested block tombstones --- .../actions/_database-block-actions.ts | 1 + .../shared/database-block-mutations.spec.ts | 55 +++++++++++++++++++ .../shared/database-block-mutations.ts | 28 +++++++++- 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/templates/content/actions/_database-block-actions.ts b/templates/content/actions/_database-block-actions.ts index b4b32d76af..8c0a7b4064 100644 --- a/templates/content/actions/_database-block-actions.ts +++ b/templates/content/actions/_database-block-actions.ts @@ -667,6 +667,7 @@ export async function mutateDatabaseBlock( identity: loaded.identity, mutation: resolved.mutation, insertedBlockId: resolved.insertedBlockId, + createInsertedDescendantId: () => `block_${nanoid(16)}`, }); } catch (error) { mapMutationFailure(error); diff --git a/templates/content/shared/database-block-mutations.spec.ts b/templates/content/shared/database-block-mutations.spec.ts index 869f6d1f3d..fb1e25943c 100644 --- a/templates/content/shared/database-block-mutations.spec.ts +++ b/templates/content/shared/database-block-mutations.spec.ts @@ -192,6 +192,60 @@ describe("individual Blocks-field document mutations", () => { expect(changed.deletedCandidateIds).toEqual([callout.id, child.id]); }); + 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); @@ -284,6 +338,7 @@ describe("individual Blocks-field document mutations", () => { position: { placement: "after", anchorBlockId: block!.id }, }, insertedBlockId: insertedId, + createInsertedDescendantId: createId, }); expect(inserted.changed).toBe(true); stored = reconcileBlocksFieldIdentity({ diff --git a/templates/content/shared/database-block-mutations.ts b/templates/content/shared/database-block-mutations.ts index 26e49ed5e1..b9ffa382c3 100644 --- a/templates/content/shared/database-block-mutations.ts +++ b/templates/content/shared/database-block-mutations.ts @@ -187,12 +187,36 @@ function preferredIds( 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 : undefined); + (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]] : []; }), ); @@ -203,6 +227,7 @@ export function mutateBlocksFieldDocument(args: { identity: BlocksFieldIdentity; mutation: BlockDocumentMutation; insertedBlockId?: string; + createInsertedDescendantId?: () => string; }): BlockDocumentMutationResult { const indexed = indexIdentity(args.markdown, args.identity); const { doc, byId, idByNode } = indexed; @@ -286,6 +311,7 @@ export function mutateBlocksFieldDocument(args: { idByNode, requestedNode, args.insertedBlockId, + args.createInsertedDescendantId, ); const identityOrderChanged = collectNodeRefs(doc).some( (ref, index) => From b9443b9a6ad78ebd2e92fad0d647e475e16d91b8 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:02:32 -0400 Subject: [PATCH 09/16] fix(content): protect additional block field writes --- .../actions/_database-block-actions.ts | 81 ++++++++++++++----- .../content-database-block-actions.db.test.ts | 74 +++++++++++++++++ 2 files changed, 137 insertions(+), 18 deletions(-) diff --git a/templates/content/actions/_database-block-actions.ts b/templates/content/actions/_database-block-actions.ts index 8c0a7b4064..64a8e4ab8e 100644 --- a/templates/content/actions/_database-block-actions.ts +++ b/templates/content/actions/_database-block-actions.ts @@ -126,6 +126,7 @@ interface LoadedField { identity: BlocksFieldIdentity; ownerEmail: string; storageTarget: BlocksStorageTarget; + storageRowExists: boolean; } function contractError( @@ -217,6 +218,7 @@ async function loadField(args: { parsePropertyOptions(definition.optionsJson), ); let markdown: string; + let storageRowExists = true; if (storageTarget === "document_body") { markdown = row.document.content; } else { @@ -235,6 +237,7 @@ async function loadField(args: { ), ), ); + storageRowExists = field !== undefined; markdown = field?.content ?? ""; } const identity = await readBlocksFieldIdentity({ @@ -250,6 +253,7 @@ async function loadField(args: { identity, ownerEmail: context.database.ownerEmail, storageTarget, + storageRowExists, }; } @@ -475,24 +479,65 @@ async function writeMarkdown( } return; } - await db - .insert(schema.documentBlockFieldContents) - .values({ - id: nanoid(), - ownerEmail: loaded.ownerEmail, - documentId: target.rowDocumentId, - propertyId: target.propertyId, - content: markdown, - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [ - schema.documentBlockFieldContents.documentId, - schema.documentBlockFieldContents.propertyId, - ], - set: { content: markdown, updatedAt: now }, - }); + 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( diff --git a/templates/content/actions/content-database-block-actions.db.test.ts b/templates/content/actions/content-database-block-actions.db.test.ts index 693fa623a1..ca2c7f690d 100644 --- a/templates/content/actions/content-database-block-actions.db.test.ts +++ b/templates/content/actions/content-database-block-actions.db.test.ts @@ -27,6 +27,7 @@ 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; const asOwner = (run: () => Promise) => runWithRequestContext({ userEmail: OWNER }, run); @@ -52,6 +53,9 @@ beforeAll(async () => { 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; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); }, 60_000); @@ -482,4 +486,74 @@ describe("exact Content database block actions", () => { .where(eq(schema.documents.id, state.target.rowDocumentId)); expect(stored?.content).toBe("Editor won\nSibling"); }); + + it("rejects a stale additional-field write without overwriting the newer whole-field value", 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"); + }); }); From 7d992b6918c4971e3dac19fb3fbc68055dbfc3c8 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:05:20 -0400 Subject: [PATCH 10/16] test(content): cover absent block field races --- .../content-database-block-actions.db.test.ts | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/templates/content/actions/content-database-block-actions.db.test.ts b/templates/content/actions/content-database-block-actions.db.test.ts index ca2c7f690d..bc9ca12533 100644 --- a/templates/content/actions/content-database-block-actions.db.test.ts +++ b/templates/content/actions/content-database-block-actions.db.test.ts @@ -487,7 +487,7 @@ describe("exact Content database block actions", () => { expect(stored?.content).toBe("Editor won\nSibling"); }); - it("rejects a stale additional-field write without overwriting the newer whole-field value", async () => { + it("rejects stale existing and absent additional-field writes without clobbering", async () => { const state = await fixture("Primary stays"); const added = await asOwner(() => configureProperty.run({ @@ -555,5 +555,56 @@ describe("exact Content database block actions", () => { ), ); 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"); }); }); From f7a936ea5203691f11d6663fccce710fbd226dd5 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:05:30 -0400 Subject: [PATCH 11/16] test(content): initialize block action postgres fixture --- .../actions/content-database-block-actions.db.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/templates/content/actions/content-database-block-actions.db.test.ts b/templates/content/actions/content-database-block-actions.db.test.ts index bc9ca12533..ab096225f3 100644 --- a/templates/content/actions/content-database-block-actions.db.test.ts +++ b/templates/content/actions/content-database-block-actions.db.test.ts @@ -2,7 +2,10 @@ import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { runWithRequestContext } from "@agent-native/core/server"; +import { + runFrameworkReleaseMigrations, + runWithRequestContext, +} from "@agent-native/core/server"; import { and, eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -56,6 +59,9 @@ beforeAll(async () => { compareAndSwapAdditionalBlocksField = ( await import("./_database-block-actions.js") ).compareAndSwapAdditionalBlocksField; + 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); From 19b48cff4514b2ab0926d82f8ddbf9962174b5a3 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:16:51 -0400 Subject: [PATCH 12/16] fix(content): expose block mutations to agents --- .../actions/_database-block-actions.ts | 27 +++++++++++++++++-- templates/content/server/agent-card.test.ts | 14 +++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/templates/content/actions/_database-block-actions.ts b/templates/content/actions/_database-block-actions.ts index 64a8e4ab8e..dc2994a07a 100644 --- a/templates/content/actions/_database-block-actions.ts +++ b/templates/content/actions/_database-block-actions.ts @@ -88,7 +88,7 @@ const mutationEnvelopeSchema = z.object({ idempotencyKey: z.string().min(1).max(200), }); -export const mutateDatabaseBlockSchema = z.discriminatedUnion("operation", [ +const mutateDatabaseBlockOperationSchema = z.discriminatedUnion("operation", [ mutationEnvelopeSchema.extend({ operation: z.literal("insert"), block: blockValueSchema, @@ -116,8 +116,31 @@ export const mutateDatabaseBlockSchema = z.discriminatedUnion("operation", [ }), ]); +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; -type MutationInput = z.infer; interface LoadedField { context: MutationContext; diff --git a/templates/content/server/agent-card.test.ts b/templates/content/server/agent-card.test.ts index 9146b55d1f..806afc152f 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"; @@ -39,6 +42,9 @@ describe("content agent card", () => { .href + `?cacheBust=${Date.now()}`; const { default: modules } = await import(registryUrl); const actions = loadActionsFromStaticRegistry(modules); + const engineToolNames = actionsToEngineTools(actions).map( + (tool) => tool.name, + ); const card = generateAgentCard( { name: "Content", @@ -58,6 +64,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, ); From c98298e59dbe6bd4b4fa87913ec8dfef4c38a60d Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:35:12 -0400 Subject: [PATCH 13/16] fix: reject raced block id collisions --- .../content/actions/_blocks-field-identity.ts | 15 +++++ .../actions/_database-block-actions.ts | 66 +++++++++++++++---- .../content-database-block-actions.db.test.ts | 30 +++++++++ 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/templates/content/actions/_blocks-field-identity.ts b/templates/content/actions/_blocks-field-identity.ts index 7b04f1b014..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"; @@ -266,6 +277,7 @@ export async function persistBlocksFieldIdentity(args: { markdown: string; expectedRevision?: number; preferredIdsByPath?: Readonly>; + rejectCrossFieldIdRemapping?: boolean; now: string; }): Promise { const fieldId = blocksFieldId(args.documentId, args.propertyId); @@ -327,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 index dc2994a07a..e32258507f 100644 --- a/templates/content/actions/_database-block-actions.ts +++ b/templates/content/actions/_database-block-actions.ts @@ -29,6 +29,7 @@ import { type DocumentPropertyType, } from "../shared/properties.js"; import { + BlocksFieldIdCollisionError, persistBlocksFieldIdentity, readBlocksFieldIdentity, } from "./_blocks-field-identity.js"; @@ -161,6 +162,17 @@ function contractError( 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, @@ -744,23 +756,55 @@ export async function mutateDatabaseBlock( let postIdentity = loaded.identity; if (changed.changed) { await writeMarkdown(tx, loaded, input.target, changed.markdown, now); - await persistBlocksFieldIdentity({ - db: tx, - ownerEmail: loaded.ownerEmail, - documentId: input.target.rowDocumentId, - propertyId: input.target.propertyId, - previousMarkdown: loaded.markdown, - markdown: changed.markdown, - expectedRevision: input.expectedFieldRevision, - preferredIdsByPath: changed.preferredIdsByPath, - now, - }); + try { + await persistBlocksFieldIdentity({ + db: tx, + ownerEmail: loaded.ownerEmail, + documentId: input.target.rowDocumentId, + propertyId: input.target.propertyId, + previousMarkdown: loaded.markdown, + markdown: changed.markdown, + 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( diff --git a/templates/content/actions/content-database-block-actions.db.test.ts b/templates/content/actions/content-database-block-actions.db.test.ts index ab096225f3..c458fdf131 100644 --- a/templates/content/actions/content-database-block-actions.db.test.ts +++ b/templates/content/actions/content-database-block-actions.db.test.ts @@ -31,6 +31,7 @@ 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); @@ -59,6 +60,8 @@ beforeAll(async () => { 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); } @@ -132,6 +135,33 @@ function envelope( } 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; From ad56fd2494dade8decb65e7b5f81ddbe364b25d2 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:03:26 -0400 Subject: [PATCH 14/16] fix(content): close block mutation review gaps --- .../actions/_database-block-actions.ts | 80 ++++++++-- .../content-database-block-actions.db.test.ts | 140 +++++++++++++++++- .../actions/mutate-content-database-block.ts | 7 +- .../shared/database-block-mutations.spec.ts | 66 +++++++++ .../shared/database-block-mutations.ts | 14 +- 5 files changed, 286 insertions(+), 21 deletions(-) diff --git a/templates/content/actions/_database-block-actions.ts b/templates/content/actions/_database-block-actions.ts index e32258507f..0d2a39e44e 100644 --- a/templates/content/actions/_database-block-actions.ts +++ b/templates/content/actions/_database-block-actions.ts @@ -1,6 +1,6 @@ import { ActionContractError } from "@agent-native/core"; import { assertAccess } from "@agent-native/core/sharing"; -import { and, eq, isNull } from "drizzle-orm"; +import { and, eq, isNull, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -30,6 +30,7 @@ import { } from "../shared/properties.js"; import { BlocksFieldIdCollisionError, + lockPrimaryBlocksFields, persistBlocksFieldIdentity, readBlocksFieldIdentity, } from "./_blocks-field-identity.js"; @@ -692,6 +693,30 @@ export async function mutateDatabaseBlock( 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", @@ -757,18 +782,43 @@ export async function mutateDatabaseBlock( if (changed.changed) { await writeMarkdown(tx, loaded, input.target, changed.markdown, now); try { - await persistBlocksFieldIdentity({ - db: tx, - ownerEmail: loaded.ownerEmail, - documentId: input.target.rowDocumentId, - propertyId: input.target.propertyId, - previousMarkdown: loaded.markdown, - markdown: changed.markdown, - expectedRevision: input.expectedFieldRevision, - preferredIdsByPath: changed.preferredIdsByPath, - rejectCrossFieldIdRemapping: true, - now, - }); + 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 || @@ -903,7 +953,5 @@ export async function mutateDatabaseBlock( }); }, ); - return result.receipt.idempotency.result === "replayed" - ? result - : verifyResult(result); + return verifyResult(result); } diff --git a/templates/content/actions/content-database-block-actions.db.test.ts b/templates/content/actions/content-database-block-actions.db.test.ts index c458fdf131..feb3d26b48 100644 --- a/templates/content/actions/content-database-block-actions.db.test.ts +++ b/templates/content/actions/content-database-block-actions.db.test.ts @@ -343,9 +343,85 @@ describe("exact Content database block actions", () => { ), ).rejects.toMatchObject({ errorCode: "BLOCK_ID_TOMBSTONED" }); - const lateReplay = await asOwner(() => mutateBlock.run(insertInput)); - expect(lateReplay.receipt.receiptId).toBe(inserted.receipt.receiptId); - expect(lateReplay.receipt.idempotency.result).toBe("replayed"); + 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 () => { @@ -370,6 +446,25 @@ describe("exact Content database block actions", () => { 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({ @@ -523,6 +618,45 @@ describe("exact Content database block actions", () => { 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(() => diff --git a/templates/content/actions/mutate-content-database-block.ts b/templates/content/actions/mutate-content-database-block.ts index 6aef986321..945bfc719d 100644 --- a/templates/content/actions/mutate-content-database-block.ts +++ b/templates/content/actions/mutate-content-database-block.ts @@ -1,4 +1,5 @@ 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"; @@ -26,7 +27,11 @@ export default defineAction({ : "Mutated Content database block"; }, }, - run: mutateDatabaseBlock, + 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; diff --git a/templates/content/shared/database-block-mutations.spec.ts b/templates/content/shared/database-block-mutations.spec.ts index fb1e25943c..0a5a8cf8fc 100644 --- a/templates/content/shared/database-block-mutations.spec.ts +++ b/templates/content/shared/database-block-mutations.spec.ts @@ -192,6 +192,72 @@ describe("individual Blocks-field document mutations", () => { 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`; diff --git a/templates/content/shared/database-block-mutations.ts b/templates/content/shared/database-block-mutations.ts index b9ffa382c3..9f0b050555 100644 --- a/templates/content/shared/database-block-mutations.ts +++ b/templates/content/shared/database-block-mutations.ts @@ -147,6 +147,17 @@ function assertCompatibleParent( (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 ? ![ @@ -167,7 +178,8 @@ function assertCompatibleParent( ? kind === "tableRow" : parentKind === "tableRow" ? kind === "tableHeader" || kind === "tableCell" - : ![ + : generalContainerKinds.has(parentKind) && + ![ "listItem", "taskItem", "notionColumn", From 1d1519bcaae747b8421a16c23e68a7c3483728bd Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:07:32 -0400 Subject: [PATCH 15/16] fix(content): regenerate merged parity matrix --- templates/content/parity/matrix.md | 35 +----------------------------- 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/templates/content/parity/matrix.md b/templates/content/parity/matrix.md index 38688dba2b..a8ec1f56ae 100644 --- a/templates/content/parity/matrix.md +++ b/templates/content/parity/matrix.md @@ -2,12 +2,11 @@ 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. -<<<<<<< HEAD | 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`, `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` | `database-source-scope` | - | +| 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` | - | @@ -33,35 +32,3 @@ This generated matrix tracks whether high-value Content UI operations use the sa | 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`, `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` | - | - | ->>>>>>> origin/main From ed6faff33f4a336c025672cb4fab748030acf5cd Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:27:04 -0400 Subject: [PATCH 16/16] fix(content): honor existing upsert position --- .../actions/_database-block-actions.ts | 1 + .../content-database-block-actions.db.test.ts | 2 + .../shared/database-block-mutations.spec.ts | 32 ++++++++ .../shared/database-block-mutations.ts | 82 ++++++++++++------- 4 files changed, 89 insertions(+), 28 deletions(-) diff --git a/templates/content/actions/_database-block-actions.ts b/templates/content/actions/_database-block-actions.ts index 0d2a39e44e..6673bed5fb 100644 --- a/templates/content/actions/_database-block-actions.ts +++ b/templates/content/actions/_database-block-actions.ts @@ -436,6 +436,7 @@ function actionMutation( operation: "upsert", blockId: input.blockId, block: input.block, + position: input.position, }, }; } diff --git a/templates/content/actions/content-database-block-actions.db.test.ts b/templates/content/actions/content-database-block-actions.db.test.ts index feb3d26b48..d7eb8395d7 100644 --- a/templates/content/actions/content-database-block-actions.db.test.ts +++ b/templates/content/actions/content-database-block-actions.db.test.ts @@ -270,9 +270,11 @@ describe("exact Content database block actions", () => { 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 }), diff --git a/templates/content/shared/database-block-mutations.spec.ts b/templates/content/shared/database-block-mutations.spec.ts index 0a5a8cf8fc..4604931e84 100644 --- a/templates/content/shared/database-block-mutations.spec.ts +++ b/templates/content/shared/database-block-mutations.spec.ts @@ -330,6 +330,38 @@ describe("individual Blocks-field document mutations", () => { ); }); + 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")!; diff --git a/templates/content/shared/database-block-mutations.ts b/templates/content/shared/database-block-mutations.ts index 9f0b050555..d6c9a5791d 100644 --- a/templates/content/shared/database-block-mutations.ts +++ b/templates/content/shared/database-block-mutations.ts @@ -194,6 +194,41 @@ function assertCompatibleParent( } } +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, @@ -273,7 +308,18 @@ export function mutateBlocksFieldDocument(args: { } 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) => { @@ -286,34 +332,14 @@ export function mutateBlocksFieldDocument(args: { .map((block) => block.id); current.nodes.splice(current.nodeIndex, 1); } else if (args.mutation.operation === "reorder") { - const position = args.mutation.position; - if ( - "anchorBlockId" in position && - position.anchorBlockId === args.mutation.blockId - ) { - throw new Error("A block cannot be reordered relative to itself."); - } - const target = destination(position, byId, doc); - if (target.parentPath !== current.parentPath) { - throw new Error("Cross-parent block reorder is not supported."); - } - current.nodes.splice(current.nodeIndex, 1); - const targetIndex = - "anchorBlockId" in position - ? (() => { - const anchor = byId.get(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 + (position.placement === "after" ? 1 : 0); - })() - : position.placement === "start" - ? 0 - : target.nodes.length; - target.nodes.splice(targetIndex, 0, current.node); + repositionNode({ + blockId: args.mutation.blockId, + current, + node: current.node, + position: args.mutation.position, + byId, + doc, + }); } }