From f83b4b8554f3066e0ccf7f658d8e9297174a6153 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:30:14 -0400 Subject: [PATCH 01/13] feat(content): upsert database items by stable key --- .../upsert-database-item-by-key.db.test.ts | 219 ++++++++ .../actions/upsert-database-item-by-key.ts | 519 ++++++++++++++++++ templates/content/parity/matrix.md | 2 +- templates/content/parity/matrix.ts | 1 + templates/content/server/agent-card.test.ts | 1 + templates/content/server/db/schema.ts | 24 + templates/content/server/plugins/db.ts | 19 + 7 files changed, 784 insertions(+), 1 deletion(-) create mode 100644 templates/content/actions/upsert-database-item-by-key.db.test.ts create mode 100644 templates/content/actions/upsert-database-item-by-key.ts 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 new file mode 100644 index 0000000000..f666980b2f --- /dev/null +++ b/templates/content/actions/upsert-database-item-by-key.db.test.ts @@ -0,0 +1,219 @@ +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runWithRequestContext } from "@agent-native/core/server"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const TEST_DB_PATH = join( + tmpdir(), + `content-key-upsert-${process.pid}-${Date.now()}.sqlite`, +); +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 upsert: typeof import("./upsert-database-item-by-key.js").default; + +const asOwner = (fn: () => Promise) => + runWithRequestContext({ userEmail: OWNER }, fn); + +beforeAll(async () => { + process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; + 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; + 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 }); +}); + +async function fixture() { + const created = await asOwner(() => + createDatabase.run({ title: "Projection" }), + ); + const property = await asOwner(() => + configureProperty.run({ + documentId: created.database.documentId, + databaseId: created.database.id, + name: "External key", + type: "text", + }), + ); + const keyProperty = property.properties.find( + (candidate) => candidate.definition.name === "External key", + ); + if (!keyProperty) throw new Error("Fixture key property was not created."); + return { + databaseId: created.database.id, + propertyId: keyProperty.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); + }); + + 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"); + }); + + 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() + .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"); + }); + + it("fails closed when a stable-key claim no longer names its exact database membership", async () => { + const { databaseId, propertyId } = await fixture(); + const created = await asOwner(() => + upsert.run({ + databaseId, + keyPropertyId: propertyId, + keyValue: "stale-claim", + title: "Original", + }), + ); + 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", + }), + ), + ).rejects.toThrow("does not resolve to a row in this database"); + const [document] = await getDb() + .select({ title: schema.documents.title }) + .from(schema.documents) + .where(eq(schema.documents.id, created.documentId)); + expect(document?.title).toBe("Original"); + }); +}); diff --git a/templates/content/actions/upsert-database-item-by-key.ts b/templates/content/actions/upsert-database-item-by-key.ts new file mode 100644 index 0000000000..07b2883889 --- /dev/null +++ b/templates/content/actions/upsert-database-item-by-key.ts @@ -0,0 +1,519 @@ +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 { 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 { + databaseItemsPositionScope, + documentsPositionScope, + withPositionLock, +} from "./_position-utils.js"; +import { nanoid, normalizedValueJson } from "./_property-utils.js"; + +const upsertSchema = z.object({ + databaseId: z.string().min(1).describe("Target Content database ID"), + keyPropertyId: 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"), + propertyValues: z + .record(z.string(), z.unknown()) + .optional() + .describe("Property values keyed by property definition ID"), +}); + +type Identity = { itemId: string; documentId: string }; + +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, + parallelSafe: true, + 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 === "workspaces") + throw new Error("Use create-content-space to add a workspace."); + + 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; + if ( + keyDefinition.systemRole || + 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."); + + 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) => { + 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 (!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 }; + } + } + + 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.", + ); + } + const [document] = await tx + .select() + .from(schema.documents) + .where(eq(schema.documents.id, identity.documentId)); + 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) + await tx + .update(schema.documents) + .set({ + ...(title !== undefined ? { title: title.trim() } : {}), + ...(body !== undefined ? { content: body } : {}), + updatedAt: now, + }) + .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, + }); + } + 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], + }); + if (readback.items.length !== 1 || readback.items[0]?.id !== result.itemId) + throw new Error( + "Stable key upsert could not verify its exact database row readback.", + ); + return { + ...result, + databaseId, + keyPropertyId, + keyValue, + readback: { items: readback.items, pagination: readback.pagination }, + }; + }, +}); diff --git a/templates/content/parity/matrix.md b/templates/content/parity/matrix.md index 23a4fb4f3c..b3d362134c 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, and delete database rows | action-backed | `add-database-item`, `delete-database-items`, `delete-document`, `duplicate-database-items`, `duplicate-database-item`, `move-database-item`, `set-document-property` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx` | Database row backing documents and row ordering are created, duplicated, moved, edited, and deleted. | - | - | P0 | covered | `actions/database-row-batch-actions.db.test.ts`, `parity/__tests__/database-row-batch-reliability.test.ts` | `database-bulk-row-reliability` | - | +| database.rows | database | Add, duplicate, move, open, and delete database rows | action-backed | `add-database-item`, `upsert-database-item-by-key`, `delete-database-items`, `delete-document`, `duplicate-database-items`, `duplicate-database-item`, `move-database-item`, `set-document-property` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx` | Database row backing documents and row ordering are created, duplicated, moved, edited, and deleted. | - | - | P0 | covered | `actions/database-row-batch-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 | - | - | - | diff --git a/templates/content/parity/matrix.ts b/templates/content/parity/matrix.ts index 239a012855..c1494480c6 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", + "upsert-database-item-by-key", "delete-database-items", "delete-document", "duplicate-database-items", diff --git a/templates/content/server/agent-card.test.ts b/templates/content/server/agent-card.test.ts index f153763629..6c4432906b 100644 --- a/templates/content/server/agent-card.test.ts +++ b/templates/content/server/agent-card.test.ts @@ -19,6 +19,7 @@ const REQUIRED_CONTENT_ACTIONS = [ "update-document", "move-document", "navigate", + "upsert-database-item-by-key", ]; const ACTION_REGISTRY_TEST_TIMEOUT_MS = 60_000; diff --git a/templates/content/server/db/schema.ts b/templates/content/server/db/schema.ts index 0f32c9d1af..abe38b9cda 100644 --- a/templates/content/server/db/schema.ts +++ b/templates/content/server/db/schema.ts @@ -287,6 +287,30 @@ 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. +export const contentDatabaseItemKeyClaims = table( + "content_database_item_key_claims", + { + id: text("id").primaryKey(), + ownerEmail: text("owner_email").notNull().default("local@localhost"), + orgId: text("org_id"), + databaseId: text("database_id").notNull(), + propertyId: text("property_id").notNull(), + keyValueJson: text("key_value_json").notNull(), + itemId: text("item_id").notNull(), + documentId: text("document_id").notNull(), + createdAt: text("created_at").notNull().default(now()), + updatedAt: text("updated_at").notNull().default(now()), + }, + (claim) => [ + uniqueIndex( + "content_database_item_key_claims_database_property_value_unique", + ).on(claim.databaseId, claim.propertyId, claim.keyValueJson), + ], +); + export const contentDatabaseBodyHydrationQueue = table( "content_database_body_hydration_queue", { diff --git a/templates/content/server/plugins/db.ts b/templates/content/server/plugins/db.ts index 016572d4fd..398f3d14aa 100644 --- a/templates/content/server/plugins/db.ts +++ b/templates/content/server/plugins/db.ts @@ -908,6 +908,25 @@ const runContentMigrations = runMigrations( WHERE legacy_database_trash.document_id = documents.id )`, }, + { + version: 78, + name: "content-database-item-stable-key-claims", + sql: `CREATE TABLE IF NOT EXISTS content_database_item_key_claims ( + id TEXT PRIMARY KEY, + owner_email TEXT NOT NULL DEFAULT 'local@localhost', + org_id TEXT, + database_id TEXT NOT NULL, + property_id TEXT NOT NULL, + key_value_json TEXT NOT NULL, + item_id TEXT NOT NULL, + document_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE UNIQUE INDEX IF NOT EXISTS content_database_item_key_claims_database_property_value_unique ON content_database_item_key_claims (database_id, property_id, key_value_json); + CREATE INDEX IF NOT EXISTS content_database_item_key_claims_item_idx ON content_database_item_key_claims (item_id); + CREATE INDEX IF NOT EXISTS content_database_item_key_claims_document_idx ON content_database_item_key_claims (document_id)`, + }, ], { table: "content_migrations" }, ); From 775a8540374faf41ca673587b88e4012e6120f34 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:42:19 -0400 Subject: [PATCH 02/13] fix(content): enforce stable key claim lifecycle --- templates/content/actions/_database-utils.ts | 6 + .../actions/delete-document-property.ts | 8 ++ .../content/actions/set-document-property.ts | 12 +- .../upsert-database-item-by-key.db.test.ts | 103 +++++++++++++++++- .../actions/upsert-database-item-by-key.ts | 54 ++++++++- templates/content/server/db/schema.ts | 3 + templates/content/server/plugins/db.ts | 5 + 7 files changed, 185 insertions(+), 6 deletions(-) diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index 11e938a9a4..0bcdea6666 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -1281,6 +1281,9 @@ export async function deleteDatabaseDataForDocument( db, ); if (database) { + await db + .delete(schema.contentDatabaseItemKeyClaims) + .where(eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id)); const definitions = await db .select({ id: schema.documentPropertyDefinitions.id }) .from(schema.documentPropertyDefinitions) @@ -1346,6 +1349,9 @@ export async function deleteDatabaseDataForDocument( db, ); if (item) { + await db + .delete(schema.contentDatabaseItemKeyClaims) + .where(eq(schema.contentDatabaseItemKeyClaims.documentId, documentId)); await db .delete(schema.contentDatabaseBodyHydrationQueue) .where( diff --git a/templates/content/actions/delete-document-property.ts b/templates/content/actions/delete-document-property.ts index 1b5f44c5b6..87c00a4f67 100644 --- a/templates/content/actions/delete-document-property.ts +++ b/templates/content/actions/delete-document-property.ts @@ -68,6 +68,14 @@ export default defineAction({ await db .delete(schema.documentPropertyValues) .where(eq(schema.documentPropertyValues.propertyId, propertyId)); + await db + .delete(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id), + eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId), + ), + ); await db .delete(schema.documentPropertyDefinitions) .where(eq(schema.documentPropertyDefinitions.id, propertyId)); diff --git a/templates/content/actions/set-document-property.ts b/templates/content/actions/set-document-property.ts index 4dddcb7f66..7660342ca3 100644 --- a/templates/content/actions/set-document-property.ts +++ b/templates/content/actions/set-document-property.ts @@ -1,6 +1,6 @@ import { defineAction } from "@agent-native/core"; import { assertAccess } from "@agent-native/core/sharing"; -import { and, eq } from "drizzle-orm"; +import { and, eq, ne } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -139,6 +139,16 @@ export default defineAction({ updatedAt: now, }); } + await db + .delete(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id), + eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId), + eq(schema.contentDatabaseItemKeyClaims.documentId, documentId), + ne(schema.contentDatabaseItemKeyClaims.keyValueJson, valueJson), + ), + ); return { documentId, 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 f666980b2f..d5175f7f2a 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 @@ -12,6 +12,7 @@ const TEST_DB_PATH = join( ); 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; @@ -19,6 +20,8 @@ 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 deleteDatabaseDataForDocument: typeof import("./_database-utils.js").deleteDatabaseDataForDocument; const asOwner = (fn: () => Promise) => runWithRequestContext({ userEmail: OWNER }, fn); @@ -32,6 +35,8 @@ beforeAll(async () => { 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; + ({ deleteDatabaseDataForDocument } = await import("./_database-utils.js")); const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); }, 60_000); @@ -106,6 +111,11 @@ describe("upsert-database-item-by-key", () => { }); 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: "", + }); }); it("uses the unique claim for concurrent first writes and preserves inherited privacy", async () => { @@ -139,6 +149,20 @@ describe("upsert-database-item-by-key", () => { .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(); }); it("denies access and fails closed for wrong-database or computed key properties", async () => { @@ -173,6 +197,46 @@ describe("upsert-database-item-by-key", () => { ).rejects.toThrow("cannot be used"); }); + 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 [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(), + }); + 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(); const created = await asOwner(() => @@ -209,11 +273,48 @@ describe("upsert-database-item-by-key", () => { title: "Would mutate if claim were trusted", }), ), - ).rejects.toThrow("does not resolve to a row in this database"); + ).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("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" }), + ); + await deleteDatabaseDataForDocument(created.documentId, OWNER); + const reused = await asOwner(() => + upsert.run({ databaseId, keyPropertyId: propertyId, keyValue: "reuse" }), + ); + expect(reused.status).toBe("created"); + expect(reused.documentId).not.toBe(created.documentId); + }); }); diff --git a/templates/content/actions/upsert-database-item-by-key.ts b/templates/content/actions/upsert-database-item-by-key.ts index 07b2883889..732b5dd25e 100644 --- a/templates/content/actions/upsert-database-item-by-key.ts +++ b/templates/content/actions/upsert-database-item-by-key.ts @@ -2,7 +2,7 @@ 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 { and, eq, inArray, isNull, ne, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -45,7 +45,6 @@ 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, - parallelSafe: true, run: async ({ databaseId, keyPropertyId, @@ -239,6 +238,11 @@ export default defineAction({ "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 = { @@ -428,6 +432,7 @@ export default defineAction({ "Stable key claim does not resolve to a row in this database.", ); } + await assertAccess("document", identity.documentId, "editor"); const [document] = await tx .select() .from(schema.documents) @@ -493,6 +498,28 @@ export default defineAction({ 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 }; }), ), @@ -504,9 +531,28 @@ export default defineAction({ offset: 0, documentIds: [result.documentId], }); - if (readback.items.length !== 1 || readback.items[0]?.id !== result.itemId) + const readbackItem = readback.items[0]; + const readbackKey = readbackItem?.properties.find( + (property) => property.definition.id === keyPropertyId, + ); + const [verifiedDocument] = await db + .select({ + id: schema.documents.id, + title: schema.documents.title, + content: schema.documents.content, + }) + .from(schema.documents) + .where(eq(schema.documents.id, result.documentId)); + if ( + readback.items.length !== 1 || + readbackItem?.id !== result.itemId || + verifiedDocument?.id !== result.documentId || + readbackKey?.value !== JSON.parse(keyValueJson) || + (title !== undefined && verifiedDocument.title !== title.trim()) || + (body !== undefined && verifiedDocument.content !== body) + ) throw new Error( - "Stable key upsert could not verify its exact database row readback.", + "Stable key upsert could not verify its exact requested row readback.", ); return { ...result, diff --git a/templates/content/server/db/schema.ts b/templates/content/server/db/schema.ts index abe38b9cda..aa2fe94a6d 100644 --- a/templates/content/server/db/schema.ts +++ b/templates/content/server/db/schema.ts @@ -308,6 +308,9 @@ export const contentDatabaseItemKeyClaims = table( uniqueIndex( "content_database_item_key_claims_database_property_value_unique", ).on(claim.databaseId, claim.propertyId, claim.keyValueJson), + uniqueIndex( + "content_database_item_key_claims_database_property_document_unique", + ).on(claim.databaseId, claim.propertyId, claim.documentId), ], ); diff --git a/templates/content/server/plugins/db.ts b/templates/content/server/plugins/db.ts index 398f3d14aa..558e88c5bb 100644 --- a/templates/content/server/plugins/db.ts +++ b/templates/content/server/plugins/db.ts @@ -927,6 +927,11 @@ const runContentMigrations = runMigrations( CREATE INDEX IF NOT EXISTS content_database_item_key_claims_item_idx ON content_database_item_key_claims (item_id); CREATE INDEX IF NOT EXISTS content_database_item_key_claims_document_idx ON content_database_item_key_claims (document_id)`, }, + { + version: 79, + name: "content-database-item-stable-key-single-active-claim", + sql: `CREATE UNIQUE INDEX IF NOT EXISTS content_database_item_key_claims_database_property_document_unique ON content_database_item_key_claims (database_id, property_id, document_id)`, + }, ], { table: "content_migrations" }, ); From 67b01f7c1d06ec2dbb464c00add1f19cd71e9152 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:53:40 -0400 Subject: [PATCH 03/13] fix(content): make stable key cleanup atomic --- .../actions/delete-document-property.ts | 140 +++++++++--------- .../content/actions/delete-document.test.ts | 4 + templates/content/actions/delete-document.ts | 21 +++ .../content/actions/set-document-property.ts | 72 ++++----- .../upsert-database-item-by-key.db.test.ts | 111 +++++++++++++- .../actions/upsert-database-item-by-key.ts | 24 ++- 6 files changed, 262 insertions(+), 110 deletions(-) diff --git a/templates/content/actions/delete-document-property.ts b/templates/content/actions/delete-document-property.ts index 87c00a4f67..9f8b54da0e 100644 --- a/templates/content/actions/delete-document-property.ts +++ b/templates/content/actions/delete-document-property.ts @@ -65,82 +65,84 @@ export default defineAction({ isBlocks && isPrimaryBlocksField(parsePropertyOptions(definition.optionsJson)); - await db - .delete(schema.documentPropertyValues) - .where(eq(schema.documentPropertyValues.propertyId, propertyId)); - await db - .delete(schema.contentDatabaseItemKeyClaims) - .where( - and( - eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id), - eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId), - ), - ); - await db - .delete(schema.documentPropertyDefinitions) - .where(eq(schema.documentPropertyDefinitions.id, propertyId)); + await db.transaction(async (tx) => { + await tx + .delete(schema.documentPropertyValues) + .where(eq(schema.documentPropertyValues.propertyId, propertyId)); + await tx + .delete(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id), + eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId), + ), + ); + await tx + .delete(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.id, propertyId)); - if (isBlocks) { - // Drop the independent content for this Blocks field across every row. - await db - .delete(schema.documentBlockFieldContents) - .where(eq(schema.documentBlockFieldContents.propertyId, propertyId)); + if (isBlocks) { + // Drop the independent content for this Blocks field across every row. + await tx + .delete(schema.documentBlockFieldContents) + .where(eq(schema.documentBlockFieldContents.propertyId, propertyId)); - // Deleting the primary "Content" field removes the body (documents.content) - // for every object of this type, per the delete warning shown in the UI. - if (isPrimaryBlocks) { - // Record that the primary was intentionally removed: clear the single - // source of truth but LEAVE blocks_seeded = 1, so neither the read path - // nor the startup repair ever recreates it. Deleting the only Blocks - // field is an allowed product action that leaves the row metadata-only - // with ZERO Blocks fields. - await db - .update(schema.contentDatabases) - .set({ - primaryBlocksPropertyId: null, - updatedAt: new Date().toISOString(), - }) - .where(eq(schema.contentDatabases.id, database.id)); + // Deleting the primary "Content" field removes the body (documents.content) + // for every object of this type, per the delete warning shown in the UI. + if (isPrimaryBlocks) { + // Record that the primary was intentionally removed: clear the single + // source of truth but LEAVE blocks_seeded = 1, so neither the read path + // nor the startup repair ever recreates it. Deleting the only Blocks + // field is an allowed product action that leaves the row metadata-only + // with ZERO Blocks fields. + await tx + .update(schema.contentDatabases) + .set({ + primaryBlocksPropertyId: null, + updatedAt: new Date().toISOString(), + }) + .where(eq(schema.contentDatabases.id, database.id)); - const items = await db - .select({ documentId: schema.contentDatabaseItems.documentId }) - .from(schema.contentDatabaseItems) - .where(eq(schema.contentDatabaseItems.databaseId, database.id)); - const documentIds = items.map((item) => item.documentId); - if (documentIds.length > 0) { - const now = new Date().toISOString(); - await db - .update(schema.documents) - .set({ content: "", updatedAt: now }) - .where(inArray(schema.documents.id, documentIds)); + const items = await tx + .select({ documentId: schema.contentDatabaseItems.documentId }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, database.id)); + const documentIds = items.map((item) => item.documentId); + if (documentIds.length > 0) { + const now = new Date().toISOString(); + await tx + .update(schema.documents) + .set({ content: "", updatedAt: now }) + .where(inArray(schema.documents.id, documentIds)); + } } } - } - // Free any source field that was mapped to this property so it returns to - // the "From source" picker immediately, instead of staying orphaned until - // the next source refresh reconciles it. - const mappedFields = await db - .select({ - id: schema.contentDatabaseSourceFields.id, - sourceFieldKey: schema.contentDatabaseSourceFields.sourceFieldKey, - }) - .from(schema.contentDatabaseSourceFields) - .where(eq(schema.contentDatabaseSourceFields.propertyId, propertyId)); - if (mappedFields.length > 0) { - const now = new Date().toISOString(); - for (const mapped of mappedFields) { - await db - .update(schema.contentDatabaseSourceFields) - .set({ - propertyId: null, - localFieldKey: mapped.sourceFieldKey, - mappingType: "property", - updatedAt: now, - }) - .where(eq(schema.contentDatabaseSourceFields.id, mapped.id)); + // Free any source field that was mapped to this property so it returns to + // the "From source" picker immediately, instead of staying orphaned until + // the next source refresh reconciles it. + const mappedFields = await tx + .select({ + id: schema.contentDatabaseSourceFields.id, + sourceFieldKey: schema.contentDatabaseSourceFields.sourceFieldKey, + }) + .from(schema.contentDatabaseSourceFields) + .where(eq(schema.contentDatabaseSourceFields.propertyId, propertyId)); + if (mappedFields.length > 0) { + const now = new Date().toISOString(); + for (const mapped of mappedFields) { + await tx + .update(schema.contentDatabaseSourceFields) + .set({ + propertyId: null, + localFieldKey: mapped.sourceFieldKey, + mappingType: "property", + updatedAt: now, + }) + .where(eq(schema.contentDatabaseSourceFields.id, mapped.id)); + } } - } + }); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/delete-document.test.ts b/templates/content/actions/delete-document.test.ts index b526d4e5fd..a63dce4acc 100644 --- a/templates/content/actions/delete-document.test.ts +++ b/templates/content/actions/delete-document.test.ts @@ -31,6 +31,10 @@ const { schema } = vi.hoisted(() => ({ documentId: "contentDatabaseItems.documentId", ownerEmail: "contentDatabaseItems.ownerEmail", }, + contentDatabaseItemKeyClaims: { + databaseId: "contentDatabaseItemKeyClaims.databaseId", + documentId: "contentDatabaseItemKeyClaims.documentId", + }, contentSpaceCatalogItems: { id: "contentSpaceCatalogItems.id", documentId: "contentSpaceCatalogItems.documentId", diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index 4432b2c65e..3f997fb761 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -416,6 +416,27 @@ async function deleteCollectedDocuments( ); }); + await deleteWhereIn(ownedDatabaseIds, async (databaseIdBatch) => { + await db + .delete(schema.contentDatabaseItemKeyClaims) + .where( + inArray( + schema.contentDatabaseItemKeyClaims.databaseId, + databaseIdBatch, + ), + ); + }); + await deleteWhereIn(documentIds, async (documentIdBatch) => { + await db + .delete(schema.contentDatabaseItemKeyClaims) + .where( + inArray( + schema.contentDatabaseItemKeyClaims.documentId, + documentIdBatch, + ), + ); + }); + await deleteWhereIn(documentIds, async (documentIdBatch) => { await db .delete(schema.contentDatabaseBodyHydrationQueue) diff --git a/templates/content/actions/set-document-property.ts b/templates/content/actions/set-document-property.ts index 7660342ca3..79bfcf836c 100644 --- a/templates/content/actions/set-document-property.ts +++ b/templates/content/actions/set-document-property.ts @@ -113,42 +113,44 @@ export default defineAction({ } const valueJson = normalizedValueJson(type, value); - const [existing] = await db - .select({ id: schema.documentPropertyValues.id }) - .from(schema.documentPropertyValues) - .where( - and( - eq(schema.documentPropertyValues.documentId, documentId), - eq(schema.documentPropertyValues.propertyId, propertyId), - ), - ); + await db.transaction(async (tx) => { + const [existing] = await tx + .select({ id: schema.documentPropertyValues.id }) + .from(schema.documentPropertyValues) + .where( + and( + eq(schema.documentPropertyValues.documentId, documentId), + eq(schema.documentPropertyValues.propertyId, propertyId), + ), + ); - if (existing) { - await db - .update(schema.documentPropertyValues) - .set({ valueJson, updatedAt: now }) - .where(eq(schema.documentPropertyValues.id, existing.id)); - } else { - await db.insert(schema.documentPropertyValues).values({ - id: nanoid(), - ownerEmail: database.ownerEmail, - documentId, - propertyId, - valueJson, - createdAt: now, - updatedAt: now, - }); - } - await db - .delete(schema.contentDatabaseItemKeyClaims) - .where( - and( - eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id), - eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId), - eq(schema.contentDatabaseItemKeyClaims.documentId, documentId), - ne(schema.contentDatabaseItemKeyClaims.keyValueJson, valueJson), - ), - ); + 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, + propertyId, + valueJson, + createdAt: now, + updatedAt: now, + }); + } + await tx + .delete(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id), + eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId), + eq(schema.contentDatabaseItemKeyClaims.documentId, documentId), + ne(schema.contentDatabaseItemKeyClaims.keyValueJson, valueJson), + ), + ); + }); return { documentId, 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 d5175f7f2a..f0993d209b 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 @@ -21,7 +21,9 @@ 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 deleteDatabaseDataForDocument: typeof import("./_database-utils.js").deleteDatabaseDataForDocument; +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; const asOwner = (fn: () => Promise) => runWithRequestContext({ userEmail: OWNER }, fn); @@ -36,7 +38,10 @@ beforeAll(async () => { .default; upsert = (await import("./upsert-database-item-by-key.js")).default; setProperty = (await import("./set-document-property.js")).default; - ({ deleteDatabaseDataForDocument } = await import("./_database-utils.js")); + deleteProperty = (await import("./delete-document-property.js")).default; + deleteDocument = (await import("./delete-document.js")).default; + permanentlyDeleteDocument = (await import("./permanently-delete-document.js")) + .default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); }, 60_000); @@ -165,6 +170,75 @@ describe("upsert-database-item-by-key", () => { ).rejects.toThrow(); }); + 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", + }), + ); + 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, + }, + }, + }), + ); + 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, + }); + }); + it("denies access and fails closed for wrong-database or computed key properties", async () => { const { databaseId, propertyId } = await fixture(); await expect( @@ -305,12 +379,43 @@ describe("upsert-database-item-by-key", () => { expect(b.documentId).toBe(created.documentId); }); + 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 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" }), ); - await deleteDatabaseDataForDocument(created.documentId, OWNER); + await asOwner(() => deleteDocument.run({ id: created.documentId })); + await expect( + asOwner(() => + upsert.run({ + databaseId, + keyPropertyId: propertyId, + keyValue: "reuse", + }), + ), + ).rejects.toThrow("trashed database row"); + await asOwner(() => + permanentlyDeleteDocument.run({ id: created.documentId }), + ); const reused = await asOwner(() => upsert.run({ databaseId, keyPropertyId: propertyId, keyValue: "reuse" }), ); diff --git a/templates/content/actions/upsert-database-item-by-key.ts b/templates/content/actions/upsert-database-item-by-key.ts index 732b5dd25e..b649ddfb97 100644 --- a/templates/content/actions/upsert-database-item-by-key.ts +++ b/templates/content/actions/upsert-database-item-by-key.ts @@ -532,8 +532,25 @@ export default defineAction({ documentIds: [result.documentId], }); const readbackItem = readback.items[0]; - const readbackKey = readbackItem?.properties.find( - (property) => property.definition.id === keyPropertyId, + 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 db .select({ @@ -547,7 +564,8 @@ export default defineAction({ readback.items.length !== 1 || readbackItem?.id !== result.itemId || verifiedDocument?.id !== result.documentId || - readbackKey?.value !== JSON.parse(keyValueJson) || + !readbackValuesMatch || + (title !== undefined && readbackItem?.document.title !== title.trim()) || (title !== undefined && verifiedDocument.title !== title.trim()) || (body !== undefined && verifiedDocument.content !== body) ) From 71eb4c16056a5ffcf6ef12e50a8441974fbf402b Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:23:21 -0400 Subject: [PATCH 04/13] fix(content): serialize stable key updates --- .../upsert-database-item-by-key.db.test.ts | 83 +++++++++++++++++++ .../actions/upsert-database-item-by-key.ts | 28 ++++++- 2 files changed, 109 insertions(+), 2 deletions(-) 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 f0993d209b..0115f6bc59 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 @@ -170,6 +170,72 @@ describe("upsert-database-item-by-key", () => { ).rejects.toThrow(); }); + 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", + }), + ); + 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", + }), + ); + 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() + .select() + .from(schema.documentPropertyValues) + .where( + and( + eq(schema.documentPropertyValues.documentId, created.documentId), + eq( + schema.documentPropertyValues.propertyId, + requestedProperty.definition.id, + ), + ), + ); + expect(stored).toHaveLength(1); + expect(stored[0]?.valueJson).toBe('"same-value"'); + }); + it("verifies every requested property by canonical serialized readback, including arrays and objects", async () => { const { databaseId, propertyId } = await fixture(); const [database] = await getDb() @@ -271,6 +337,23 @@ describe("upsert-database-item-by-key", () => { ).rejects.toThrow("cannot be used"); }); + 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", + }), + ), + ).rejects.toThrow("ordinary Content databases"); + }); + 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(() => diff --git a/templates/content/actions/upsert-database-item-by-key.ts b/templates/content/actions/upsert-database-item-by-key.ts index b649ddfb97..d7265317f2 100644 --- a/templates/content/actions/upsert-database-item-by-key.ts +++ b/templates/content/actions/upsert-database-item-by-key.ts @@ -64,8 +64,11 @@ export default defineAction({ ), ); if (!database) throw new Error(`Database "${databaseId}" not found.`); - if (database.systemRole === "workspaces") - throw new Error("Use create-content-space to add a workspace."); + if (database.systemRole) { + throw new Error( + "Stable-key upserts are supported only for ordinary Content databases, not system databases.", + ); + } const access = await assertAccess( "document", @@ -411,6 +414,27 @@ export default defineAction({ } } + // 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, From b1f07f4eaf7ddb19eb3e904e0a3d73605705cf1b Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:53:08 -0400 Subject: [PATCH 05/13] fix(content): close stable key lifecycle races --- ...d-content-database-source-field.db.test.ts | 37 ++++ .../bind-content-database-source-field.ts | 170 +++++++++++------- templates/content/actions/delete-document.ts | 20 ++- .../upsert-database-item-by-key.db.test.ts | 127 +++++++++++++ .../actions/upsert-database-item-by-key.ts | 99 +++++++++- 5 files changed, 381 insertions(+), 72 deletions(-) diff --git a/templates/content/actions/bind-content-database-source-field.db.test.ts b/templates/content/actions/bind-content-database-source-field.db.test.ts index ee26a0696f..0941bbfb4a 100644 --- a/templates/content/actions/bind-content-database-source-field.db.test.ts +++ b/templates/content/actions/bind-content-database-source-field.db.test.ts @@ -429,6 +429,43 @@ describe("bind-content-database-source-field (row-union)", () => { ).rejects.toThrow(/multi-value/i); }); + it("rejects binding a property that already owns stable-key claims", async () => { + const f = await seedRowUnion(); + const db = getDb(); + const [item] = await db + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.documentId, f.docs.a1)); + const now = new Date().toISOString(); + await db.insert(schema.contentDatabaseItemKeyClaims).values({ + id: `claim_${f.databaseId}`, + ownerEmail: OWNER, + orgId: null, + databaseId: f.databaseId, + propertyId: f.tagPropertyId, + keyValueJson: '"claimed"', + itemId: item.id, + documentId: f.docs.a1, + createdAt: now, + updatedAt: now, + }); + + await expect( + asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: f.tagPropertyId, + }), + ), + ).rejects.toThrow(/active stable-key claims/i); + const [field] = await db + .select({ propertyId: schema.contentDatabaseSourceFields.propertyId }) + .from(schema.contentDatabaseSourceFields) + .where(eq(schema.contentDatabaseSourceFields.id, f.fields.fieldACat)); + expect(field.propertyId).toBeNull(); + }); + it("allows two different sources to feed one column, then unbinds", async () => { const f = await seedRowUnion(); await asOwner(() => diff --git a/templates/content/actions/bind-content-database-source-field.ts b/templates/content/actions/bind-content-database-source-field.ts index e706be76af..8e103465d4 100644 --- a/templates/content/actions/bind-content-database-source-field.ts +++ b/templates/content/actions/bind-content-database-source-field.ts @@ -1,6 +1,6 @@ import { defineAction } from "@agent-native/core"; import { assertAccess } from "@agent-native/core/sharing"; -import { and, eq, inArray, ne } from "drizzle-orm"; +import { and, eq, inArray, isNull, ne, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -8,7 +8,10 @@ import type { BindContentDatabaseSourceFieldRequest, ContentDatabaseResponse, } from "../shared/api.js"; -import { serializePropertyValue } from "../shared/properties.js"; +import { + serializePropertyValue, + type DocumentPropertyType, +} from "../shared/properties.js"; import { chunks } from "./_batch-utils.js"; import { resolveDatabaseForSourceMutation } from "./_database-source-utils.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; @@ -183,23 +186,6 @@ export default defineAction({ ); } - await db - .update(schema.contentDatabaseSourceFields) - .set({ - propertyId: property.id, - localFieldKey: property.id, - mappingType: "property", - updatedAt: now, - }) - .where(eq(schema.contentDatabaseSourceFields.id, field.id)); - await db - .update(schema.contentDatabaseSources) - .set({ updatedAt: now }) - .where(eq(schema.contentDatabaseSources.id, source.id)); - - // Backfill the column with this source's per-row values. A federated - // secondary's rows carry no local document (the read path overlays them), - // so only materialize for document-backed sources. let federationRole: string | null = null; try { const parsed = JSON.parse(source.metadataJson ?? "{}") as { @@ -209,57 +195,109 @@ export default defineAction({ } catch { federationRole = null; } - if (federationRole !== "secondary") { - const sourceRows = await db - .select({ - databaseItemId: schema.contentDatabaseSourceRows.databaseItemId, - documentId: schema.contentDatabaseSourceRows.documentId, - sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson, + await db.transaction(async (tx) => { + // Share the database-row lock used by stable-key upserts. This makes the + // claim check and source binding one atomic ownership transition: either + // the property remains caller-managed, or binding fails before backfill. + const [lockedDatabase] = await tx + .update(schema.contentDatabases) + .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` }) + .where( + and( + eq(schema.contentDatabases.id, database.id), + eq(schema.contentDatabases.documentId, database.documentId), + eq(schema.contentDatabases.ownerEmail, database.ownerEmail), + isNull(schema.contentDatabases.deletedAt), + ), + ) + .returning({ id: schema.contentDatabases.id }); + if (!lockedDatabase) throw new Error("Database is no longer active."); + + const [activeClaim] = await tx + .select({ id: schema.contentDatabaseItemKeyClaims.id }) + .from(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id), + eq(schema.contentDatabaseItemKeyClaims.propertyId, property.id), + ), + ) + .limit(1); + if (activeClaim) { + throw new Error( + "A property with active stable-key claims cannot be bound to a source field.", + ); + } + + await tx + .update(schema.contentDatabaseSourceFields) + .set({ + propertyId: property.id, + localFieldKey: property.id, + mappingType: "property", + updatedAt: now, }) - .from(schema.contentDatabaseSourceRows) - .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id)); - const itemValues = sourceFieldPropertyValuesFromRows( - sourceRows, - field.sourceFieldKey, - property.type, - ); - // Clear this column's values for ALL of this source's rows first — not - // just the rows that now have a value — so a row whose new bound field is - // empty doesn't keep showing a stale/previous value. Then write the - // non-empty ones. (This source owns these documents' values for the row- - // union, so clearing them is safe.) - const sourceDocumentIds = sourceRows - .map((row) => row.documentId) - .filter((id): id is string => Boolean(id)); - if (sourceDocumentIds.length > 0) { - await db - .delete(schema.documentPropertyValues) - .where( - and( - eq(schema.documentPropertyValues.propertyId, property.id), - inArray( - schema.documentPropertyValues.documentId, - sourceDocumentIds, + .where(eq(schema.contentDatabaseSourceFields.id, field.id)); + await tx + .update(schema.contentDatabaseSources) + .set({ updatedAt: now }) + .where(eq(schema.contentDatabaseSources.id, source.id)); + + // Backfill the column with this source's per-row values. A federated + // secondary's rows carry no local document (the read path overlays them), + // so only materialize for document-backed sources. + if (federationRole !== "secondary") { + const sourceRows = await tx + .select({ + databaseItemId: schema.contentDatabaseSourceRows.databaseItemId, + documentId: schema.contentDatabaseSourceRows.documentId, + sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson, + }) + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id)); + const itemValues = sourceFieldPropertyValuesFromRows( + sourceRows, + field.sourceFieldKey, + property.type as DocumentPropertyType, + ); + // Clear this column's values for ALL of this source's rows first — not + // just the rows that now have a value — so a row whose new bound field is + // empty doesn't keep showing a stale/previous value. Then write the + // non-empty ones. (This source owns these documents' values for the row- + // union, so clearing them is safe.) + const sourceDocumentIds = sourceRows + .map((row) => row.documentId) + .filter((id): id is string => Boolean(id)); + if (sourceDocumentIds.length > 0) { + await tx + .delete(schema.documentPropertyValues) + .where( + and( + eq(schema.documentPropertyValues.propertyId, property.id), + inArray( + schema.documentPropertyValues.documentId, + sourceDocumentIds, + ), ), - ), - ); - } - if (itemValues.length > 0) { - for (const chunk of chunks(itemValues, 200)) { - await db.insert(schema.documentPropertyValues).values( - chunk.map((row) => ({ - id: nanoid(), - ownerEmail: database.ownerEmail, - documentId: row.documentId, - propertyId: property.id, - valueJson: serializePropertyValue(row.value), - createdAt: now, - updatedAt: now, - })), - ); + ); + } + if (itemValues.length > 0) { + for (const chunk of chunks(itemValues, 200)) { + await tx.insert(schema.documentPropertyValues).values( + chunk.map((row) => ({ + id: nanoid(), + ownerEmail: database.ownerEmail, + documentId: row.documentId, + propertyId: property.id, + valueJson: serializePropertyValue(row.value), + createdAt: now, + updatedAt: now, + })), + ); + } } } - } + }); return getContentDatabaseResponse(database.id, { limit: 100, offset: 0 }); }, diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index 3f997fb761..802f0f6c99 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -1,7 +1,7 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; -import { and, eq, inArray, isNotNull, isNull, ne, or } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -162,6 +162,24 @@ export async function trashDocumentSubtree( ownerEmail: string, trashedAt = new Date().toISOString(), ): Promise { + const initial = await collectDocumentSubtreeForDelete(db, id, ownerEmail); + // Stable-key upserts lock their canonical database row before creating or + // updating children. Acquire the same locks, then collect again: an upsert + // that won the lock before this transaction may have added a child after the + // first traversal, while one that arrives later must wait until the database + // has been marked deleted and will fail closed. + for (const batch of chunks(initial.ownedDatabaseIds, DELETE_BATCH_SIZE)) { + await db + .update(schema.contentDatabases) + .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` }) + .where( + and( + inArray(schema.contentDatabases.id, batch), + eq(schema.contentDatabases.ownerEmail, ownerEmail), + isNull(schema.contentDatabases.deletedAt), + ), + ); + } const { documentIds } = await collectDocumentSubtreeForDelete( db, id, 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 0115f6bc59..3662e51ccd 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,6 +2,7 @@ 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"; @@ -170,6 +171,42 @@ describe("upsert-database-item-by-key", () => { ).rejects.toThrow(); }); + 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", + }), + ); + 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", + }), + ); + + 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(() => @@ -236,6 +273,54 @@ describe("upsert-database-item-by-key", () => { 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); + }); + it("verifies every requested property by canonical serialized readback, including arrays and objects", async () => { const { databaseId, propertyId } = await fixture(); const [database] = await getDb() @@ -354,6 +439,48 @@ describe("upsert-database-item-by-key", () => { ).rejects.toThrow("ordinary Content databases"); }); + 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, + }); + 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, + }); + + await expect( + asOwner(() => + upsert.run({ + databaseId, + keyPropertyId: propertyId, + keyValue: "source-owned", + }), + ), + ).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("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(() => diff --git a/templates/content/actions/upsert-database-item-by-key.ts b/templates/content/actions/upsert-database-item-by-key.ts index d7265317f2..9a5f56af7f 100644 --- a/templates/content/actions/upsert-database-item-by-key.ts +++ b/templates/content/actions/upsert-database-item-by-key.ts @@ -110,8 +110,26 @@ export default defineAction({ `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) ) { @@ -166,6 +184,59 @@ export default defineAction({ () => 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.`, + ); + } + + const [transactionSourceManagedKey] = await tx + .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 (transactionSourceManagedKey) { + throw new Error( + `Property "${keyDefinition.name}" cannot be used as a stable key.`, + ); + } + const matches = await tx .select({ itemId: schema.contentDatabaseItems.id, @@ -457,10 +528,20 @@ export default defineAction({ ); } 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 - .select() - .from(schema.documents) - .where(eq(schema.documents.id, identity.documentId)); + .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.", @@ -495,15 +576,23 @@ export default defineAction({ (body !== undefined && document.content !== body); if (!documentChanged && changedValues.length === 0) return { status: "unchanged" as const, ...identity }; - if (documentChanged) + 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: now, + updatedAt, }) .where(eq(schema.documents.id, identity.documentId)); + } for (const [propertyId, valueJson] of changedValues) { const existing = existingByProperty.get(propertyId); if (existing) From fb2d75b8c9d9ce5e393290843e2a4048953c18a7 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:08:03 -0400 Subject: [PATCH 06/13] fix(content): serialize property lifecycle --- .../actions/configure-document-property.ts | 133 +++++++++++++----- .../actions/delete-document-property.ts | 39 ++++- .../upsert-database-item-by-key.db.test.ts | 86 +++++++++++ .../actions/upsert-database-item-by-key.ts | 59 ++++++++ 4 files changed, 284 insertions(+), 33 deletions(-) diff --git a/templates/content/actions/configure-document-property.ts b/templates/content/actions/configure-document-property.ts index 99a569f38c..104d2d6a42 100644 --- a/templates/content/actions/configure-document-property.ts +++ b/templates/content/actions/configure-document-property.ts @@ -1,7 +1,7 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, isNull, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -114,12 +114,13 @@ export default defineAction({ } if (args.id) { + const propertyId = args.id; const [existing] = await db .select() .from(schema.documentPropertyDefinitions) .where( and( - eq(schema.documentPropertyDefinitions.id, args.id), + eq(schema.documentPropertyDefinitions.id, propertyId), eq( schema.documentPropertyDefinitions.ownerEmail, document.ownerEmail, @@ -127,7 +128,7 @@ export default defineAction({ eq(schema.documentPropertyDefinitions.databaseId, database.id), ), ); - if (!existing) throw new Error(`Property "${args.id}" not found`); + if (!existing) throw new Error(`Property "${propertyId}" not found`); if (existing.systemRole) { throw new Error("System properties cannot be changed."); } @@ -158,42 +159,110 @@ export default defineAction({ optionsJson = serializePropertyOptions({ blocks: { primary: true } }); } - if (existing.type !== type) { - await db - .delete(schema.documentPropertyValues) + await db.transaction(async (tx) => { + const [lockedDatabase] = await tx + .update(schema.contentDatabases) + .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` }) .where( and( - eq(schema.documentPropertyValues.propertyId, args.id), - eq(schema.documentPropertyValues.ownerEmail, document.ownerEmail), + eq(schema.contentDatabases.id, database.id), + eq(schema.contentDatabases.documentId, database.documentId), + eq(schema.contentDatabases.ownerEmail, document.ownerEmail), + isNull(schema.contentDatabases.deletedAt), ), - ); - // Switching a Blocks field to another type drops its independent content. + ) + .returning({ id: schema.contentDatabases.id }); + if (!lockedDatabase) throw new Error("Database is no longer active."); + const [lockedExisting] = await tx + .update(schema.documentPropertyDefinitions) + .set({ + updatedAt: sql`${schema.documentPropertyDefinitions.updatedAt}`, + }) + .where( + and( + eq(schema.documentPropertyDefinitions.id, propertyId), + eq( + schema.documentPropertyDefinitions.ownerEmail, + document.ownerEmail, + ), + eq(schema.documentPropertyDefinitions.databaseId, database.id), + ), + ) + .returning(); + if (!lockedExisting) + throw new Error(`Property "${propertyId}" not found`); + if (lockedExisting.systemRole) + throw new Error("System properties cannot be changed."); if ( - isBlocksPropertyType(existing.type as DocumentPropertyType) && - !isBlocksPropertyType(type) + isComputedPropertyType(lockedExisting.type as DocumentPropertyType) && + lockedExisting.type !== type ) { - await db - .delete(schema.documentBlockFieldContents) - .where(eq(schema.documentBlockFieldContents.propertyId, args.id)); + throw new Error("Computed property types cannot be changed."); + } + const lockedIsPrimaryBlocks = + isBlocksPropertyType(lockedExisting.type as DocumentPropertyType) && + isPrimaryBlocksField( + parsePropertyOptions(lockedExisting.optionsJson), + ); + if (lockedIsPrimaryBlocks && lockedExisting.type !== type) { + throw new Error( + "The primary Content (Blocks) field cannot change type. Delete it from the database view to remove the body.", + ); } - } - await db - .update(schema.documentPropertyDefinitions) - .set({ - name, - ...(args.description === undefined - ? {} - : { description: args.description.trim() }), - type, - visibility: - args.visibility === undefined - ? normalizePropertyVisibility(existing.visibility) - : normalizePropertyVisibility(args.visibility), - optionsJson, - updatedAt: now, - }) - .where(eq(schema.documentPropertyDefinitions.id, args.id)); + if (lockedExisting.type !== type) { + await tx + .delete(schema.documentPropertyValues) + .where( + and( + eq(schema.documentPropertyValues.propertyId, propertyId), + eq( + schema.documentPropertyValues.ownerEmail, + document.ownerEmail, + ), + ), + ); + await tx + .delete(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id), + eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId), + ), + ); + // Switching a Blocks field to another type drops its independent content. + if ( + isBlocksPropertyType(lockedExisting.type as DocumentPropertyType) && + !isBlocksPropertyType(type) + ) { + await tx + .delete(schema.documentBlockFieldContents) + .where( + eq(schema.documentBlockFieldContents.propertyId, propertyId), + ); + } + } + + await tx + .update(schema.documentPropertyDefinitions) + .set({ + name, + ...(args.description === undefined + ? {} + : { description: args.description.trim() }), + type, + visibility: + args.visibility === undefined + ? normalizePropertyVisibility(lockedExisting.visibility) + : normalizePropertyVisibility(args.visibility), + optionsJson: + lockedIsPrimaryBlocks && isBlocksPropertyType(type) + ? serializePropertyOptions({ blocks: { primary: true } }) + : optionsJson, + updatedAt: now, + }) + .where(eq(schema.documentPropertyDefinitions.id, propertyId)); + }); } else { await withPositionLock( propertyDefinitionsPositionScope(database.id), diff --git a/templates/content/actions/delete-document-property.ts b/templates/content/actions/delete-document-property.ts index 9f8b54da0e..61964b002d 100644 --- a/templates/content/actions/delete-document-property.ts +++ b/templates/content/actions/delete-document-property.ts @@ -1,7 +1,7 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { assertAccess } from "@agent-native/core/sharing"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -66,6 +66,43 @@ export default defineAction({ isPrimaryBlocksField(parsePropertyOptions(definition.optionsJson)); await db.transaction(async (tx) => { + const [lockedDatabase] = await tx + .update(schema.contentDatabases) + .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` }) + .where( + and( + eq(schema.contentDatabases.id, database.id), + eq(schema.contentDatabases.documentId, database.documentId), + eq(schema.contentDatabases.ownerEmail, document.ownerEmail), + isNull(schema.contentDatabases.deletedAt), + ), + ) + .returning({ id: schema.contentDatabases.id }); + if (!lockedDatabase) throw new Error("Database is no longer active."); + const [lockedDefinition] = await tx + .update(schema.documentPropertyDefinitions) + .set({ + updatedAt: sql`${schema.documentPropertyDefinitions.updatedAt}`, + }) + .where( + and( + eq(schema.documentPropertyDefinitions.id, propertyId), + eq( + schema.documentPropertyDefinitions.ownerEmail, + document.ownerEmail, + ), + eq(schema.documentPropertyDefinitions.databaseId, database.id), + ), + ) + .returning({ + id: schema.documentPropertyDefinitions.id, + systemRole: schema.documentPropertyDefinitions.systemRole, + }); + if (!lockedDefinition) + throw new Error(`Property "${propertyId}" not found`); + if (lockedDefinition.systemRole) + throw new Error("System properties cannot be deleted."); + await tx .delete(schema.documentPropertyValues) .where(eq(schema.documentPropertyValues.propertyId, propertyId)); 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 3662e51ccd..315621fa26 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 @@ -321,6 +321,47 @@ describe("upsert-database-item-by-key", () => { expect(lateDocument?.trashRootId).toBe(database.documentId); }); + 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`, + ); + try { + await expect( + asOwner(() => + upsert.run({ + databaseId, + keyPropertyId: propertyId, + keyValue: "deleted-during-upsert", + }), + ), + ).rejects.toThrow("changed or was deleted"); + } finally { + await getDbExec().execute(`DROP TRIGGER IF EXISTS ${triggerName}`); + } + + const claims = await getDb() + .select() + .from(schema.contentDatabaseItemKeyClaims) + .where(eq(schema.contentDatabaseItemKeyClaims.databaseId, databaseId)); + const items = await getDb() + .select() + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); + expect(claims).toHaveLength(0); + expect(items).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() @@ -589,6 +630,51 @@ describe("upsert-database-item-by-key", () => { expect(b.documentId).toBe(created.documentId); }); + 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([ + asOwner(() => + upsert.run({ + databaseId, + keyPropertyId: propertyId, + keyValue: "not-a-number", + }), + ), + asOwner(() => + configureProperty.run({ + id: propertyId, + documentId: database.documentId, + databaseId, + name: "External key", + type: "number", + }), + ), + ]); + 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); + }); + it("removes stable-key claims in the same property-definition deletion", async () => { const { databaseId, propertyId } = await fixture(); const created = await asOwner(() => diff --git a/templates/content/actions/upsert-database-item-by-key.ts b/templates/content/actions/upsert-database-item-by-key.ts index 9a5f56af7f..b75f2f78a4 100644 --- a/templates/content/actions/upsert-database-item-by-key.ts +++ b/templates/content/actions/upsert-database-item-by-key.ts @@ -211,6 +211,65 @@ export default defineAction({ ); } + // 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 [transactionSourceManagedKey] = await tx .select({ id: schema.contentDatabaseSourceFields.id }) .from(schema.contentDatabaseSourceFields) From 3094c19cc910fb5db6258dac437f6061d81f1606 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:18:32 -0400 Subject: [PATCH 07/13] fix(content): unify stable key lock order --- ...d-content-database-source-field.db.test.ts | 49 ++++++++++++++++ .../bind-content-database-source-field.ts | 29 +++++++++- .../actions/configure-document-property.ts | 12 ++++ .../content/actions/set-document-property.ts | 57 ++++++++++++++++++- .../upsert-database-item-by-key.db.test.ts | 43 ++++++++++++++ 5 files changed, 188 insertions(+), 2 deletions(-) diff --git a/templates/content/actions/bind-content-database-source-field.db.test.ts b/templates/content/actions/bind-content-database-source-field.db.test.ts index 0941bbfb4a..2eee37ab1a 100644 --- a/templates/content/actions/bind-content-database-source-field.db.test.ts +++ b/templates/content/actions/bind-content-database-source-field.db.test.ts @@ -28,6 +28,7 @@ let getDb: () => any; let schema: typeof import("../server/db/schema.js"); let bindAction: typeof import("./bind-content-database-source-field.js").default; let addSourceFieldPropertyAction: typeof import("./add-content-database-source-field-property.js").default; +let configureProperty: typeof import("./configure-document-property.js").default; const OWNER = "owner@example.com"; @@ -40,6 +41,8 @@ beforeAll(async () => { await plugin(undefined as any); bindAction = (await import("./bind-content-database-source-field.js")) .default; + configureProperty = (await import("./configure-document-property.js")) + .default; const addSourceFieldPropertyModule = await import("./add-content-database-source-field-property.js"); addSourceFieldPropertyAction = addSourceFieldPropertyModule.default; @@ -466,6 +469,52 @@ describe("bind-content-database-source-field (row-union)", () => { expect(field.propertyId).toBeNull(); }); + it("serializes a real concurrent source binding and property type change", async () => { + const f = await seedRowUnion(); + const [database] = await getDb() + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, f.databaseId)); + const [bindResult, configureResult] = await Promise.allSettled([ + asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: f.tagPropertyId, + }), + ), + asOwner(() => + configureProperty.run({ + id: f.tagPropertyId, + documentId: database.documentId, + databaseId: f.databaseId, + name: "Tag", + type: "number", + }), + ), + ]); + expect( + [bindResult, configureResult].filter( + (result) => result.status === "fulfilled", + ), + ).toHaveLength(1); + + const [field] = await getDb() + .select({ propertyId: schema.contentDatabaseSourceFields.propertyId }) + .from(schema.contentDatabaseSourceFields) + .where(eq(schema.contentDatabaseSourceFields.id, f.fields.fieldACat)); + const [property] = await getDb() + .select({ type: schema.documentPropertyDefinitions.type }) + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.id, f.tagPropertyId)); + if (field.propertyId) { + expect(field.propertyId).toBe(f.tagPropertyId); + expect(property.type).toBe("text"); + } else { + expect(property.type).toBe("number"); + } + }); + it("allows two different sources to feed one column, then unbinds", async () => { const f = await seedRowUnion(); await asOwner(() => diff --git a/templates/content/actions/bind-content-database-source-field.ts b/templates/content/actions/bind-content-database-source-field.ts index 8e103465d4..248905208a 100644 --- a/templates/content/actions/bind-content-database-source-field.ts +++ b/templates/content/actions/bind-content-database-source-field.ts @@ -213,6 +213,33 @@ export default defineAction({ .returning({ id: schema.contentDatabases.id }); if (!lockedDatabase) throw new Error("Database is no longer active."); + const [lockedProperty] = await tx + .update(schema.documentPropertyDefinitions) + .set({ + updatedAt: sql`${schema.documentPropertyDefinitions.updatedAt}`, + }) + .where( + and( + eq(schema.documentPropertyDefinitions.id, property.id), + eq(schema.documentPropertyDefinitions.databaseId, database.id), + eq( + schema.documentPropertyDefinitions.ownerEmail, + database.ownerEmail, + ), + ), + ) + .returning(); + if ( + !lockedProperty || + lockedProperty.type !== property.type || + lockedProperty.systemRole !== property.systemRole || + lockedProperty.name !== property.name + ) { + throw new Error( + "Target column changed or was deleted before the source field could be bound.", + ); + } + const [activeClaim] = await tx .select({ id: schema.contentDatabaseItemKeyClaims.id }) .from(schema.contentDatabaseItemKeyClaims) @@ -258,7 +285,7 @@ export default defineAction({ const itemValues = sourceFieldPropertyValuesFromRows( sourceRows, field.sourceFieldKey, - property.type as DocumentPropertyType, + lockedProperty.type as DocumentPropertyType, ); // Clear this column's values for ALL of this source's rows first — not // just the rows that now have a value — so a row whose new bound field is diff --git a/templates/content/actions/configure-document-property.ts b/templates/content/actions/configure-document-property.ts index 104d2d6a42..1e88053c87 100644 --- a/templates/content/actions/configure-document-property.ts +++ b/templates/content/actions/configure-document-property.ts @@ -211,6 +211,18 @@ export default defineAction({ } if (lockedExisting.type !== type) { + const [mappedSourceField] = await tx + .select({ id: schema.contentDatabaseSourceFields.id }) + .from(schema.contentDatabaseSourceFields) + .where( + eq(schema.contentDatabaseSourceFields.propertyId, propertyId), + ) + .limit(1); + if (mappedSourceField) { + throw new Error( + "A property bound to a source field must be unbound before changing its type.", + ); + } 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 79bfcf836c..5094f63cbb 100644 --- a/templates/content/actions/set-document-property.ts +++ b/templates/content/actions/set-document-property.ts @@ -1,6 +1,6 @@ import { defineAction } from "@agent-native/core"; import { assertAccess } from "@agent-native/core/sharing"; -import { and, eq, ne } from "drizzle-orm"; +import { and, eq, isNull, ne, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -114,6 +114,61 @@ export default defineAction({ const valueJson = normalizedValueJson(type, value); await db.transaction(async (tx) => { + const [lockedDatabase] = await tx + .update(schema.contentDatabases) + .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` }) + .where( + and( + eq(schema.contentDatabases.id, database.id), + eq(schema.contentDatabases.documentId, database.documentId), + eq(schema.contentDatabases.ownerEmail, database.ownerEmail), + isNull(schema.contentDatabases.deletedAt), + ), + ) + .returning({ id: schema.contentDatabases.id }); + if (!lockedDatabase) throw new Error("Database is no longer active."); + const [lockedDefinition] = await tx + .update(schema.documentPropertyDefinitions) + .set({ + updatedAt: sql`${schema.documentPropertyDefinitions.updatedAt}`, + }) + .where( + and( + eq(schema.documentPropertyDefinitions.id, propertyId), + eq(schema.documentPropertyDefinitions.databaseId, database.id), + eq( + schema.documentPropertyDefinitions.ownerEmail, + database.ownerEmail, + ), + ), + ) + .returning({ + type: schema.documentPropertyDefinitions.type, + systemRole: schema.documentPropertyDefinitions.systemRole, + }); + if ( + !lockedDefinition || + lockedDefinition.type !== definition.type || + lockedDefinition.systemRole + ) { + throw new Error( + `Property "${propertyId}" changed or was deleted before its value could be written.`, + ); + } + const [lockedMembership] = await tx + .update(schema.contentDatabaseItems) + .set({ updatedAt: sql`${schema.contentDatabaseItems.updatedAt}` }) + .where( + and( + eq(schema.contentDatabaseItems.id, membership.id), + eq(schema.contentDatabaseItems.databaseId, database.id), + eq(schema.contentDatabaseItems.documentId, documentId), + ), + ) + .returning({ id: schema.contentDatabaseItems.id }); + if (!lockedMembership) + throw new Error("Document is no longer part of this database."); + const [existing] = await tx .select({ id: schema.documentPropertyValues.id }) .from(schema.documentPropertyValues) 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 315621fa26..e69baaf7c4 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 @@ -630,6 +630,49 @@ describe("upsert-database-item-by-key", () => { 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" }), + ), + asOwner(() => + setProperty.run({ + documentId: created.documentId, + databaseId, + propertyId, + value: "B", + }), + ), + ]); + + 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); + }); + it("serializes a real concurrent type change and retires old-type claims", async () => { const { databaseId, propertyId } = await fixture(); const [database] = await getDb() From c58ee7adceffaaf232f23b2e8e241de08f200b4e Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:33:10 -0400 Subject: [PATCH 08/13] fix(content): close stable key removal races --- ...d-content-database-source-field.db.test.ts | 34 +++++++ .../bind-content-database-source-field.ts | 46 ++++++++- .../database-row-batch-actions.db.test.ts | 23 +++++ .../content/actions/delete-document.test.ts | 50 ++++++++++ templates/content/actions/delete-document.ts | 96 ++++++++++++------- .../content/actions/remove-database-items.ts | 13 +++ 6 files changed, 226 insertions(+), 36 deletions(-) diff --git a/templates/content/actions/bind-content-database-source-field.db.test.ts b/templates/content/actions/bind-content-database-source-field.db.test.ts index 2eee37ab1a..6ef8b7f8d9 100644 --- a/templates/content/actions/bind-content-database-source-field.db.test.ts +++ b/templates/content/actions/bind-content-database-source-field.db.test.ts @@ -419,6 +419,40 @@ describe("bind-content-database-source-field (row-union)", () => { ).rejects.toThrow(/already feeds this column/i); }); + it("serializes concurrent fields from one source competing for one column", async () => { + const f = await seedRowUnion(); + const results = await Promise.allSettled([ + asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: f.tagPropertyId, + }), + ), + asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldAOther, + propertyId: f.tagPropertyId, + }), + ), + ]); + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + + const mappings = await getDb() + .select({ id: schema.contentDatabaseSourceFields.id }) + .from(schema.contentDatabaseSourceFields) + .where( + and( + eq(schema.contentDatabaseSourceFields.sourceId, f.sourceA), + eq(schema.contentDatabaseSourceFields.propertyId, f.tagPropertyId), + ), + ); + expect(mappings).toHaveLength(1); + }); + it("rejects a multi-value field into a text column", async () => { const f = await seedRowUnion(); await expect( diff --git a/templates/content/actions/bind-content-database-source-field.ts b/templates/content/actions/bind-content-database-source-field.ts index 248905208a..d48dc2ab66 100644 --- a/templates/content/actions/bind-content-database-source-field.ts +++ b/templates/content/actions/bind-content-database-source-field.ts @@ -240,6 +240,48 @@ export default defineAction({ ); } + const [lockedField] = await tx + .select() + .from(schema.contentDatabaseSourceFields) + .where(eq(schema.contentDatabaseSourceFields.id, field.id)); + if ( + !lockedField || + lockedField.sourceId !== source.id || + lockedField.mappingType === "title" || + lockedField.mappingType === "system" || + lockedField.writeOwner === "derived" + ) { + throw new Error( + "Source field changed or was deleted before it could be bound.", + ); + } + if ( + lockedField.propertyId && + lockedField.propertyId !== lockedProperty.id + ) { + throw new Error( + "This source field is already bound to another column. Unbind it first.", + ); + } + const [lockedConflictingField] = await tx + .select({ id: schema.contentDatabaseSourceFields.id }) + .from(schema.contentDatabaseSourceFields) + .where( + and( + eq(schema.contentDatabaseSourceFields.sourceId, source.id), + eq( + schema.contentDatabaseSourceFields.propertyId, + lockedProperty.id, + ), + ne(schema.contentDatabaseSourceFields.id, lockedField.id), + ), + ); + if (lockedConflictingField) { + throw new Error( + "This source already feeds this column from another field. Unbind it first.", + ); + } + const [activeClaim] = await tx .select({ id: schema.contentDatabaseItemKeyClaims.id }) .from(schema.contentDatabaseItemKeyClaims) @@ -264,7 +306,7 @@ export default defineAction({ mappingType: "property", updatedAt: now, }) - .where(eq(schema.contentDatabaseSourceFields.id, field.id)); + .where(eq(schema.contentDatabaseSourceFields.id, lockedField.id)); await tx .update(schema.contentDatabaseSources) .set({ updatedAt: now }) @@ -284,7 +326,7 @@ export default defineAction({ .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id)); const itemValues = sourceFieldPropertyValuesFromRows( sourceRows, - field.sourceFieldKey, + lockedField.sourceFieldKey, lockedProperty.type as DocumentPropertyType, ); // Clear this column's values for ALL of this source's rows first — not 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 6c0a04dc56..d3e4b43ca0 100644 --- a/templates/content/actions/database-row-batch-actions.db.test.ts +++ b/templates/content/actions/database-row-batch-actions.db.test.ts @@ -536,6 +536,18 @@ describe("database row batch actions", () => { createdAt: now, updatedAt: now, }); + await db.insert(schema.contentDatabaseItemKeyClaims).values({ + id: nextId("stable_key_claim"), + ownerEmail: OWNER, + orgId: null, + databaseId, + propertyId, + keyValueJson: JSON.stringify("Remove me"), + itemId: rows[1].itemId, + documentId: rows[1].documentId, + createdAt: now, + updatedAt: now, + }); const result = await runWithRequestContext({ userEmail: OWNER }, () => removeDatabaseItemsAction.run({ @@ -597,6 +609,17 @@ describe("database row batch actions", () => { eq(schema.documentBlockFieldContents.documentId, rows[1].documentId), ), ).resolves.toEqual([]); + await expect( + db + .select() + .from(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, databaseId), + eq(schema.contentDatabaseItemKeyClaims.itemId, rows[1].itemId), + ), + ), + ).resolves.toEqual([]); await expect( runWithRequestContext({ userEmail: OWNER }, () => diff --git a/templates/content/actions/delete-document.test.ts b/templates/content/actions/delete-document.test.ts index a63dce4acc..b91db66ea6 100644 --- a/templates/content/actions/delete-document.test.ts +++ b/templates/content/actions/delete-document.test.ts @@ -150,6 +150,11 @@ describe("deleteDocumentRecursive", () => { deleteCalls.push({ table: name, cond }); }, }), + update: () => ({ + set: () => ({ + where: async () => [], + }), + }), }; }); @@ -235,6 +240,51 @@ describe("deleteDocumentRecursive", () => { }); }); + it("recollects database rows after acquiring the permanent-cleanup lock", async () => { + selectRows.contentDatabases = [ + { + id: "database-1", + documentId: "database-doc", + ownerEmail: "owner-a@example.com", + }, + ]; + selectRows.contentDatabaseItems = [ + { + databaseId: "database-1", + documentId: "row-doc-1", + ownerEmail: "owner-a@example.com", + }, + ]; + selectRows.documents = [ + { id: "row-doc-1", ownerEmail: "owner-a@example.com" }, + ]; + db.update = () => ({ + set: () => ({ + where: async () => { + selectRows.contentDatabaseItems.push({ + databaseId: "database-1", + documentId: "late-row-doc", + ownerEmail: "owner-a@example.com", + }); + selectRows.documents.push({ + id: "late-row-doc", + ownerEmail: "owner-a@example.com", + }); + }, + }), + }); + + const deleted = await deleteDocumentRecursive( + db, + "database-doc", + "owner-a@example.com", + ); + + expect(deleted.sort()).toEqual( + ["database-doc", "late-row-doc", "row-doc-1"].sort(), + ); + }); + it("does not collect foreign-owned database item documents", async () => { selectRows.contentDatabases = [ { diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index 802f0f6c99..88752f383b 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -156,34 +156,49 @@ async function collectDocumentSubtreeForDelete( }; } +async function lockDatabasesAndRecollect< + T extends { ownedDatabaseIds: string[] }, +>( + db: ReturnType, + ownerEmail: string, + collect: () => Promise, +): Promise { + const lockedDatabaseIds = new Set(); + let collected = await collect(); + while (true) { + const unlockedDatabaseIds = collected.ownedDatabaseIds.filter( + (databaseId) => !lockedDatabaseIds.has(databaseId), + ); + if (unlockedDatabaseIds.length === 0) return collected; + for (const batch of chunks(unlockedDatabaseIds, DELETE_BATCH_SIZE)) { + await db + .update(schema.contentDatabases) + .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` }) + .where( + and( + inArray(schema.contentDatabases.id, batch), + eq(schema.contentDatabases.ownerEmail, ownerEmail), + ), + ); + for (const databaseId of batch) lockedDatabaseIds.add(databaseId); + } + collected = await collect(); + } +} + export async function trashDocumentSubtree( db: ReturnType, id: string, ownerEmail: string, trashedAt = new Date().toISOString(), ): Promise { - const initial = await collectDocumentSubtreeForDelete(db, id, ownerEmail); // Stable-key upserts lock their canonical database row before creating or // updating children. Acquire the same locks, then collect again: an upsert // that won the lock before this transaction may have added a child after the // first traversal, while one that arrives later must wait until the database // has been marked deleted and will fail closed. - for (const batch of chunks(initial.ownedDatabaseIds, DELETE_BATCH_SIZE)) { - await db - .update(schema.contentDatabases) - .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` }) - .where( - and( - inArray(schema.contentDatabases.id, batch), - eq(schema.contentDatabases.ownerEmail, ownerEmail), - isNull(schema.contentDatabases.deletedAt), - ), - ); - } - const { documentIds } = await collectDocumentSubtreeForDelete( - db, - id, - ownerEmail, + const { documentIds } = await lockDatabasesAndRecollect(db, ownerEmail, () => + collectDocumentSubtreeForDelete(db, id, ownerEmail), ); await assertNotWorkspaceCatalogDocuments(db, documentIds, "deleted"); @@ -334,8 +349,11 @@ export async function deleteDocumentRecursive( id: string, ownerEmail: string, ): Promise { - const { documentIds, ownedDatabaseIds } = - await collectDocumentSubtreeForDelete(db, id, ownerEmail); + const { documentIds, ownedDatabaseIds } = await lockDatabasesAndRecollect( + db, + ownerEmail, + () => collectDocumentSubtreeForDelete(db, id, ownerEmail), + ); return deleteCollectedDocuments( db, documentIds, @@ -576,23 +594,33 @@ export async function deleteTrashedDocumentSubtree( ); } - const documentIds = ( - await db - .select({ id: schema.documents.id }) - .from(schema.documents) - .where( - and( - eq(schema.documents.ownerEmail, ownerEmail), - eq(schema.documents.trashRootId, id), - isNotNull(schema.documents.trashedAt), - ), - ) - ).map((document) => document.id); - const ownedDatabaseIds = await selectOwnedDatabaseIds( + const { documentIds, ownedDatabaseIds } = await lockDatabasesAndRecollect( db, - documentIds, ownerEmail, - ).then((rows) => rows.map((database) => database.id)); + async () => { + const collectedDocumentIds = ( + await db + .select({ id: schema.documents.id }) + .from(schema.documents) + .where( + and( + eq(schema.documents.ownerEmail, ownerEmail), + eq(schema.documents.trashRootId, id), + isNotNull(schema.documents.trashedAt), + ), + ) + ).map((document) => document.id); + const collectedDatabaseIds = await selectOwnedDatabaseIds( + db, + collectedDocumentIds, + ownerEmail, + ).then((rows) => rows.map((database) => database.id)); + return { + documentIds: collectedDocumentIds, + ownedDatabaseIds: collectedDatabaseIds, + }; + }, + ); await db .update(schema.documents) diff --git a/templates/content/actions/remove-database-items.ts b/templates/content/actions/remove-database-items.ts index 2b991f5ba8..cfa0077da3 100644 --- a/templates/content/actions/remove-database-items.ts +++ b/templates/content/actions/remove-database-items.ts @@ -145,6 +145,19 @@ export default defineAction({ ), ); } + if (removedItemIds.length > 0) { + await tx + .delete(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id), + inArray( + schema.contentDatabaseItemKeyClaims.itemId, + removedItemIds, + ), + ), + ); + } if (removedItemIds.length > 0) { await tx .delete(schema.contentDatabaseItems) From 8ecd7ce907a4b5f1bd6b3bd44a231fc4594aa631 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:05:38 -0400 Subject: [PATCH 09/13] fix(content): enforce stable key value uniqueness --- .../database-row-batch-actions.db.test.ts | 55 +++++++++++++++++++ .../actions/duplicate-database-item.ts | 27 +++++++++ .../actions/duplicate-database-items.ts | 32 ++++++++++- .../content/actions/set-document-property.ts | 17 ++++++ .../upsert-database-item-by-key.db.test.ts | 34 ++++++++++++ .../actions/upsert-database-item-by-key.ts | 14 ++--- 6 files changed, 170 insertions(+), 9 deletions(-) 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 d3e4b43ca0..bb266eb3e9 100644 --- a/templates/content/actions/database-row-batch-actions.db.test.ts +++ b/templates/content/actions/database-row-batch-actions.db.test.ts @@ -386,6 +386,61 @@ describe("database row batch actions", () => { ]); }); + it("rejects single and batch duplication of a stable-key claimed row", async () => { + const db = getDb(); + const { databaseId, rows } = await createDatabaseWithRows(1); + const now = new Date().toISOString(); + const propertyId = nextId("claimed_property"); + await db.insert(schema.documentPropertyDefinitions).values({ + id: propertyId, + ownerEmail: OWNER, + databaseId, + name: "External ID", + type: "text", + visibility: "always_show", + optionsJson: "{}", + position: 0, + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.documentPropertyValues).values({ + id: nextId("claimed_value"), + ownerEmail: OWNER, + documentId: rows[0].documentId, + propertyId, + valueJson: JSON.stringify("external-1"), + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseItemKeyClaims).values({ + id: nextId("claimed_key"), + ownerEmail: OWNER, + orgId: null, + databaseId, + propertyId, + keyValueJson: JSON.stringify("external-1"), + itemId: rows[0].itemId, + documentId: rows[0].documentId, + createdAt: now, + updatedAt: now, + }); + + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + duplicateDatabaseItemAction.run({ itemId: rows[0].itemId }), + ), + ).rejects.toThrow(/active stable-key claims/i); + await expect( + runWithRequestContext({ userEmail: OWNER }, () => + duplicateDatabaseItemsAction.run({ + databaseId, + itemIds: [rows[0].itemId], + }), + ), + ).rejects.toThrow(/active stable-key claims/i); + expect(await orderedRows(databaseId)).toHaveLength(1); + }); + it("rejects mixed database duplicate batches before writing", async () => { const first = await createDatabaseWithRows(2); const second = await createDatabaseWithRows(1); diff --git a/templates/content/actions/duplicate-database-item.ts b/templates/content/actions/duplicate-database-item.ts index 9bcb548120..2168b1e569 100644 --- a/templates/content/actions/duplicate-database-item.ts +++ b/templates/content/actions/duplicate-database-item.ts @@ -88,6 +88,33 @@ export default defineAction({ .where(eq(schema.documentShares.resourceId, row.database.documentId)); await db.transaction(async (tx) => { + const [lockedDatabase] = await tx + .update(schema.contentDatabases) + .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` }) + .where( + and( + eq(schema.contentDatabases.id, row.database.id), + eq(schema.contentDatabases.ownerEmail, row.database.ownerEmail), + isNull(schema.contentDatabases.deletedAt), + ), + ) + .returning({ id: schema.contentDatabases.id }); + if (!lockedDatabase) throw new Error("Database is no longer active."); + const [claimedSource] = await tx + .select({ id: schema.contentDatabaseItemKeyClaims.id }) + .from(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, row.database.id), + eq(schema.contentDatabaseItemKeyClaims.documentId, row.document.id), + ), + ) + .limit(1); + if (claimedSource) { + throw new Error( + "Rows with active stable-key claims cannot be duplicated.", + ); + } await tx .update(schema.contentDatabaseItems) .set({ diff --git a/templates/content/actions/duplicate-database-items.ts b/templates/content/actions/duplicate-database-items.ts index ae6f00d713..3ef6456dc6 100644 --- a/templates/content/actions/duplicate-database-items.ts +++ b/templates/content/actions/duplicate-database-items.ts @@ -2,7 +2,7 @@ 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, gte, inArray, sql } from "drizzle-orm"; +import { and, eq, gte, inArray, isNull, sql } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; import { ensureDocumentsFilesMembership } from "./_content-files.js"; @@ -86,6 +86,36 @@ export default defineAction({ })); await db.transaction(async (tx) => { + const [lockedDatabase] = await tx + .update(schema.contentDatabases) + .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` }) + .where( + and( + eq(schema.contentDatabases.id, database.id), + eq(schema.contentDatabases.ownerEmail, database.ownerEmail), + isNull(schema.contentDatabases.deletedAt), + ), + ) + .returning({ id: schema.contentDatabases.id }); + if (!lockedDatabase) throw new Error("Database is no longer active."); + const [claimedSource] = await tx + .select({ id: schema.contentDatabaseItemKeyClaims.id }) + .from(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id), + inArray( + schema.contentDatabaseItemKeyClaims.documentId, + sourceDocumentIds, + ), + ), + ) + .limit(1); + if (claimedSource) { + throw new Error( + "Rows with active stable-key claims cannot be duplicated.", + ); + } await tx .update(schema.contentDatabaseItems) .set({ diff --git a/templates/content/actions/set-document-property.ts b/templates/content/actions/set-document-property.ts index 3676abefb8..d6d00098bf 100644 --- a/templates/content/actions/set-document-property.ts +++ b/templates/content/actions/set-document-property.ts @@ -172,6 +172,23 @@ export default defineAction({ ); } await lockDatabaseMemberships(tx, [membership.id]); + const [conflictingClaim] = await tx + .select({ id: schema.contentDatabaseItemKeyClaims.id }) + .from(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id), + eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId), + eq(schema.contentDatabaseItemKeyClaims.keyValueJson, valueJson), + ne(schema.contentDatabaseItemKeyClaims.documentId, documentId), + ), + ) + .limit(1); + if (conflictingClaim) { + throw new Error( + "This value is already claimed as another row's stable key.", + ); + } const [existing] = await tx .select({ id: schema.documentPropertyValues.id }) .from(schema.documentPropertyValues) 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 e69baaf7c4..99ec550156 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 @@ -7,6 +7,8 @@ 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 rows directly. + const TEST_DB_PATH = join( tmpdir(), `content-key-upsert-${process.pid}-${Date.now()}.sqlite`, @@ -673,6 +675,38 @@ describe("upsert-database-item-by-key", () => { expect(aClaims).toEqual(aValues); }); + 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" }), + ); + + await expect( + asOwner(() => + setProperty.run({ + documentId: a.documentId, + databaseId, + propertyId, + value: "B", + }), + ), + ).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() diff --git a/templates/content/actions/upsert-database-item-by-key.ts b/templates/content/actions/upsert-database-item-by-key.ts index b75f2f78a4..ac3c3decd5 100644 --- a/templates/content/actions/upsert-database-item-by-key.ts +++ b/templates/content/actions/upsert-database-item-by-key.ts @@ -19,6 +19,7 @@ import { withPositionLock, } from "./_position-utils.js"; import { nanoid, normalizedValueJson } from "./_property-utils.js"; +import getDocument from "./get-document.js"; const upsertSchema = z.object({ databaseId: z.string().min(1).describe("Target Content database ID"), @@ -724,14 +725,11 @@ export default defineAction({ ); }, ); - const [verifiedDocument] = await db - .select({ - id: schema.documents.id, - title: schema.documents.title, - content: schema.documents.content, - }) - .from(schema.documents) - .where(eq(schema.documents.id, result.documentId)); + const verifiedDocument = await getDocument.run({ + id: result.documentId, + databaseId, + databaseDocumentId: database.documentId, + }); if ( readback.items.length !== 1 || readbackItem?.id !== result.itemId || From ce0033223fd48591971f595a0bf0defc764008d6 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:29:42 -0400 Subject: [PATCH 10/13] fix(content): retain database locks through deletion --- .../actions/delete-content-database.ts | 12 ++-- .../content/actions/delete-document.test.ts | 26 ++++++++ templates/content/actions/delete-document.ts | 65 ++++++++++++++----- .../actions/permanently-delete-document.ts | 10 ++- 4 files changed, 83 insertions(+), 30 deletions(-) diff --git a/templates/content/actions/delete-content-database.ts b/templates/content/actions/delete-content-database.ts index 6312903b20..eb5e171a0e 100644 --- a/templates/content/actions/delete-content-database.ts +++ b/templates/content/actions/delete-content-database.ts @@ -21,13 +21,11 @@ export default defineAction({ await assertAccess("document", database.documentId, "admin"); const db = getDb(); const deletedAt = database.deletedAt ?? new Date().toISOString(); - await db.transaction((tx) => - trashDocumentSubtree( - tx as unknown as ReturnType, - database.documentId, - database.ownerEmail, - deletedAt, - ), + await trashDocumentSubtree( + db, + database.documentId, + database.ownerEmail, + deletedAt, ); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/delete-document.test.ts b/templates/content/actions/delete-document.test.ts index b91db66ea6..40bdbf575f 100644 --- a/templates/content/actions/delete-document.test.ts +++ b/templates/content/actions/delete-document.test.ts @@ -127,18 +127,24 @@ describe("deleteDocumentRecursive", () => { let deleteCalls: DeleteCall[]; let selectRows: Record[]>; let db: any; + let transactionDepth: number; + let operationsOutsideTransaction: string[]; beforeEach(() => { deleteCalls = []; selectRows = { documents: [], }; + transactionDepth = 0; + operationsOutsideTransaction = []; db = { select: () => ({ from: (table: Record) => ({ where: async (cond: any) => { const name = tableNameFor(Object.values(table)[0] as string); + if (transactionDepth === 0) + operationsOutsideTransaction.push(`select:${name}`); const rows = selectRows[name] ?? []; return rows.filter((row) => matches(row, cond)); }, @@ -147,6 +153,8 @@ describe("deleteDocumentRecursive", () => { delete: (table: Record) => ({ where: async (cond: any) => { const name = tableNameFor(Object.values(table)[0] as string); + if (transactionDepth === 0) + operationsOutsideTransaction.push(`delete:${name}`); deleteCalls.push({ table: name, cond }); }, }), @@ -155,9 +163,24 @@ describe("deleteDocumentRecursive", () => { where: async () => [], }), }), + transaction: async (run: (tx: unknown) => Promise) => { + transactionDepth += 1; + try { + return await run(db); + } finally { + transactionDepth -= 1; + } + }, }; }); + it("keeps lock, final recollection, and cleanup on one transaction handle", async () => { + await deleteDocumentRecursive(db, "doc-1", "owner-a@example.com"); + + expect(operationsOutsideTransaction).toEqual([]); + expect(transactionDepth).toBe(0); + }); + it("deletes document_comments rows for the document being deleted (n38)", async () => { await deleteDocumentRecursive(db, "doc-1", "owner-a@example.com"); @@ -261,6 +284,8 @@ describe("deleteDocumentRecursive", () => { db.update = () => ({ set: () => ({ where: async () => { + if (transactionDepth === 0) + operationsOutsideTransaction.push("update:contentDatabases"); selectRows.contentDatabaseItems.push({ databaseId: "database-1", documentId: "late-row-doc", @@ -283,6 +308,7 @@ describe("deleteDocumentRecursive", () => { expect(deleted.sort()).toEqual( ["database-doc", "late-row-doc", "row-doc-1"].sort(), ); + expect(operationsOutsideTransaction).toEqual([]); }); it("does not collect foreign-owned database item documents", async () => { diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index 88752f383b..b0b26100d0 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -191,6 +191,22 @@ export async function trashDocumentSubtree( id: string, ownerEmail: string, trashedAt = new Date().toISOString(), +): Promise { + return db.transaction((tx) => + trashDocumentSubtreeInTransaction( + tx as unknown as ReturnType, + id, + ownerEmail, + trashedAt, + ), + ); +} + +async function trashDocumentSubtreeInTransaction( + db: ReturnType, + id: string, + ownerEmail: string, + trashedAt: string, ): Promise { // Stable-key upserts lock their canonical database row before creating or // updating children. Acquire the same locks, then collect again: an upsert @@ -349,17 +365,20 @@ export async function deleteDocumentRecursive( id: string, ownerEmail: string, ): Promise { - const { documentIds, ownedDatabaseIds } = await lockDatabasesAndRecollect( - db, - ownerEmail, - () => collectDocumentSubtreeForDelete(db, id, ownerEmail), - ); - return deleteCollectedDocuments( - db, - documentIds, - ownedDatabaseIds, - ownerEmail, - ); + return db.transaction(async (tx) => { + const scopedDb = tx as unknown as ReturnType; + const { documentIds, ownedDatabaseIds } = await lockDatabasesAndRecollect( + scopedDb, + ownerEmail, + () => collectDocumentSubtreeForDelete(scopedDb, id, ownerEmail), + ); + return deleteCollectedDocuments( + scopedDb, + documentIds, + ownedDatabaseIds, + ownerEmail, + ); + }); } async function deleteCollectedDocuments( @@ -575,6 +594,20 @@ export async function deleteTrashedDocumentSubtree( db: ReturnType, id: string, ownerEmail: string, +): Promise { + return db.transaction((tx) => + deleteTrashedDocumentSubtreeInTransaction( + tx as unknown as ReturnType, + id, + ownerEmail, + ), + ); +} + +async function deleteTrashedDocumentSubtreeInTransaction( + db: ReturnType, + id: string, + ownerEmail: string, ): Promise { const [root] = await db .select({ id: schema.documents.id }) @@ -700,12 +733,10 @@ export default defineAction({ if (systemDatabase?.systemRole) { throw new Error("System Content database documents cannot be deleted"); } - const deleted = await db.transaction((tx) => - trashDocumentSubtree( - tx as unknown as ReturnType, - id, - existing.ownerEmail as string, - ), + const deleted = await trashDocumentSubtree( + db, + id, + existing.ownerEmail as string, ); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/content/actions/permanently-delete-document.ts b/templates/content/actions/permanently-delete-document.ts index 39c6d1745c..6911944922 100644 --- a/templates/content/actions/permanently-delete-document.ts +++ b/templates/content/actions/permanently-delete-document.ts @@ -15,12 +15,10 @@ export default defineAction({ run: async ({ id }) => { const access = await assertAccess("document", id, "admin"); const db = getDb(); - const deleted = await db.transaction((tx) => - deleteTrashedDocumentSubtree( - tx as unknown as ReturnType, - id, - access.resource.ownerEmail as string, - ), + const deleted = await deleteTrashedDocumentSubtree( + db, + id, + access.resource.ownerEmail as string, ); await writeAppState("refresh-signal", { ts: Date.now() }); return { success: true, deleted: deleted.length }; From 00ad5f310d426a0a57b2edd40e9744b7c081463e Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:47:44 -0400 Subject: [PATCH 11/13] fix(content): close source transition races --- ...d-content-database-source-field.db.test.ts | 72 ++++++++++++++ .../bind-content-database-source-field.ts | 25 ++++- .../change-content-database-source-role.ts | 94 +++++++++++++------ 3 files changed, 158 insertions(+), 33 deletions(-) diff --git a/templates/content/actions/bind-content-database-source-field.db.test.ts b/templates/content/actions/bind-content-database-source-field.db.test.ts index 6ef8b7f8d9..39400931ee 100644 --- a/templates/content/actions/bind-content-database-source-field.db.test.ts +++ b/templates/content/actions/bind-content-database-source-field.db.test.ts @@ -7,6 +7,7 @@ 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, inArray } from "drizzle-orm"; import { @@ -29,6 +30,7 @@ let schema: typeof import("../server/db/schema.js"); let bindAction: typeof import("./bind-content-database-source-field.js").default; let addSourceFieldPropertyAction: typeof import("./add-content-database-source-field-property.js").default; let configureProperty: typeof import("./configure-document-property.js").default; +let removeRowsOwnedOnlyBySource: typeof import("./change-content-database-source-role.js").removeRowsOwnedOnlyBySource; const OWNER = "owner@example.com"; @@ -46,6 +48,9 @@ beforeAll(async () => { const addSourceFieldPropertyModule = await import("./add-content-database-source-field-property.js"); addSourceFieldPropertyAction = addSourceFieldPropertyModule.default; + removeRowsOwnedOnlyBySource = ( + await import("./change-content-database-source-role.js") + ).removeRowsOwnedOnlyBySource; }, 60000); afterEach(() => { @@ -326,6 +331,73 @@ async function seedStaleBuilderTopicsSnapshot(rowCount = 2) { } describe("bind-content-database-source-field (row-union)", () => { + it("fails closed when the source field disappears before the bind update", async () => { + const f = await seedRowUnion(); + const triggerName = `delete_bound_field_${counter}`; + await getDbExec().execute( + `CREATE TRIGGER ${triggerName} + BEFORE UPDATE OF property_id ON content_database_source_fields + WHEN OLD.id = '${f.fields.fieldACat}' AND NEW.property_id IS NOT NULL + BEGIN + DELETE FROM content_database_source_fields WHERE id = OLD.id; + END`, + ); + try { + await expect( + asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: f.tagPropertyId, + }), + ), + ).rejects.toThrow(/deleted before its binding could be saved/i); + } finally { + await getDbExec().execute(`DROP TRIGGER IF EXISTS ${triggerName}`); + } + }); + + it("removes stable-key claims with memberships owned only by a converted source", async () => { + const f = await seedRowUnion(); + const db = getDb(); + const [claimedItem] = await db + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.documentId, f.docs.a1)); + const now = new Date().toISOString(); + await db.insert(schema.contentDatabaseItemKeyClaims).values({ + id: `claim_${claimedItem.id}`, + ownerEmail: OWNER, + orgId: null, + databaseId: f.databaseId, + propertyId: f.tagPropertyId, + keyValueJson: JSON.stringify("external-a1"), + itemId: claimedItem.id, + documentId: f.docs.a1, + createdAt: now, + updatedAt: now, + }); + + await removeRowsOwnedOnlyBySource({ + databaseId: f.databaseId, + sourceId: f.sourceA, + }); + + expect( + await db + .select({ id: schema.contentDatabaseItemKeyClaims.id }) + .from(schema.contentDatabaseItemKeyClaims) + .where( + eq(schema.contentDatabaseItemKeyClaims.id, `claim_${claimedItem.id}`), + ), + ).toEqual([]); + const remainingItems = await db + .select({ documentId: schema.contentDatabaseItems.documentId }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, f.databaseId)); + expect(remainingItems).toEqual([{ documentId: f.docs.b1 }]); + }); + it("backfills only the bound source's rows into the column", async () => { const f = await seedRowUnion(); await asOwner(() => diff --git a/templates/content/actions/bind-content-database-source-field.ts b/templates/content/actions/bind-content-database-source-field.ts index d48dc2ab66..ca61a862bd 100644 --- a/templates/content/actions/bind-content-database-source-field.ts +++ b/templates/content/actions/bind-content-database-source-field.ts @@ -298,7 +298,7 @@ export default defineAction({ ); } - await tx + const [updatedField] = await tx .update(schema.contentDatabaseSourceFields) .set({ propertyId: property.id, @@ -306,11 +306,28 @@ export default defineAction({ mappingType: "property", updatedAt: now, }) - .where(eq(schema.contentDatabaseSourceFields.id, lockedField.id)); - await tx + .where(eq(schema.contentDatabaseSourceFields.id, lockedField.id)) + .returning({ id: schema.contentDatabaseSourceFields.id }); + if (!updatedField) { + throw new Error( + "Source field was deleted before its binding could be saved.", + ); + } + const [updatedSource] = await tx .update(schema.contentDatabaseSources) .set({ updatedAt: now }) - .where(eq(schema.contentDatabaseSources.id, source.id)); + .where( + and( + eq(schema.contentDatabaseSources.id, source.id), + eq(schema.contentDatabaseSources.databaseId, database.id), + ), + ) + .returning({ id: schema.contentDatabaseSources.id }); + if (!updatedSource) { + throw new Error( + "Source was deleted before its field binding could be saved.", + ); + } // Backfill the column with this source's per-row values. A federated // secondary's rows carry no local document (the read path overlays them), diff --git a/templates/content/actions/change-content-database-source-role.ts b/templates/content/actions/change-content-database-source-role.ts index eda799326c..22a6811a32 100644 --- a/templates/content/actions/change-content-database-source-role.ts +++ b/templates/content/actions/change-content-database-source-role.ts @@ -1,6 +1,6 @@ import { defineAction } from "@agent-native/core"; import { assertAccess } from "@agent-native/core/sharing"; -import { and, asc, eq, inArray, ne } from "drizzle-orm"; +import { and, asc, eq, inArray, isNull, ne, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -157,41 +157,77 @@ async function clearSourceFederation(sourceId: string, now: string) { }); } -async function removeRowsOwnedOnlyBySource(args: { +export async function removeRowsOwnedOnlyBySource(args: { databaseId: string; sourceId: string; }) { const db = getDb(); - const [targetRows, otherRows] = await Promise.all([ - db - .select() - .from(schema.contentDatabaseSourceRows) - .where(eq(schema.contentDatabaseSourceRows.sourceId, args.sourceId)), - db + await db.transaction(async (tx) => { + const [lockedDatabase] = await tx + .update(schema.contentDatabases) + .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` }) + .where( + and( + eq(schema.contentDatabases.id, args.databaseId), + isNull(schema.contentDatabases.deletedAt), + ), + ) + .returning({ id: schema.contentDatabases.id }); + if (!lockedDatabase) throw new Error("Database is no longer active."); + + const targetRows = await tx .select() .from(schema.contentDatabaseSourceRows) - .where(ne(schema.contentDatabaseSourceRows.sourceId, args.sourceId)), - ]); - const otherDocumentIds = new Set( - otherRows.map((row) => row.documentId).filter(Boolean), - ); - const itemIds = targetRows - .filter( - (row) => - row.databaseItemId && - row.documentId && - !otherDocumentIds.has(row.documentId), - ) - .map((row) => row.databaseItemId); - if (itemIds.length === 0) return; - await db - .delete(schema.contentDatabaseItems) - .where( - and( - eq(schema.contentDatabaseItems.databaseId, args.databaseId), - inArray(schema.contentDatabaseItems.id, itemIds), - ), + .where(eq(schema.contentDatabaseSourceRows.sourceId, args.sourceId)); + const siblingSources = await tx + .select({ id: schema.contentDatabaseSources.id }) + .from(schema.contentDatabaseSources) + .where( + and( + eq(schema.contentDatabaseSources.databaseId, args.databaseId), + ne(schema.contentDatabaseSources.id, args.sourceId), + ), + ); + const otherRows = siblingSources.length + ? await tx + .select() + .from(schema.contentDatabaseSourceRows) + .where( + inArray( + schema.contentDatabaseSourceRows.sourceId, + siblingSources.map((source) => source.id), + ), + ) + : []; + const otherDocumentIds = new Set( + otherRows.map((row) => row.documentId).filter(Boolean), ); + const itemIds = targetRows + .filter( + (row) => + row.databaseItemId && + row.documentId && + !otherDocumentIds.has(row.documentId), + ) + .map((row) => row.databaseItemId as string); + if (itemIds.length === 0) return; + await tx + .delete(schema.contentDatabaseItemKeyClaims) + .where( + and( + eq(schema.contentDatabaseItemKeyClaims.databaseId, args.databaseId), + inArray(schema.contentDatabaseItemKeyClaims.itemId, itemIds), + ), + ); + await tx + .delete(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, args.databaseId), + inArray(schema.contentDatabaseItems.id, itemIds), + ), + ); + }); } export async function readBuilderCmsEntriesForRoleChange( From c717ed8eb7935eac0c60356e1ca2c63d08e97936 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:14:19 -0400 Subject: [PATCH 12/13] fix(content): serialize stable key ownership edges --- ...d-content-database-source-field.db.test.ts | 41 +++++++ .../bind-content-database-source-field.ts | 116 +++++++++++++----- .../content/actions/delete-document.test.ts | 46 +++++++ templates/content/actions/delete-document.ts | 52 +++++++- .../upsert-database-item-by-key.db.test.ts | 56 +++++++++ .../actions/upsert-database-item-by-key.ts | 27 ++-- 6 files changed, 294 insertions(+), 44 deletions(-) diff --git a/templates/content/actions/bind-content-database-source-field.db.test.ts b/templates/content/actions/bind-content-database-source-field.db.test.ts index 39400931ee..3b66c8e189 100644 --- a/templates/content/actions/bind-content-database-source-field.db.test.ts +++ b/templates/content/actions/bind-content-database-source-field.db.test.ts @@ -412,6 +412,47 @@ describe("bind-content-database-source-field (row-union)", () => { expect(await tagValue(f.docs.b1, f.tagPropertyId)).toBeUndefined(); }); + it("serializes concurrent bind and unbind without mapping/value divergence", async () => { + const f = await seedRowUnion(); + await asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: f.tagPropertyId, + }), + ); + + await Promise.allSettled([ + asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: null, + }), + ), + asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: f.tagPropertyId, + }), + ), + ]); + + const [field] = await getDb() + .select({ propertyId: schema.contentDatabaseSourceFields.propertyId }) + .from(schema.contentDatabaseSourceFields) + .where(eq(schema.contentDatabaseSourceFields.id, f.fields.fieldACat)); + if (field.propertyId === null) { + expect(await tagValue(f.docs.a1, f.tagPropertyId)).toBeUndefined(); + expect(await tagValue(f.docs.a2, f.tagPropertyId)).toBeUndefined(); + } else { + expect(field.propertyId).toBe(f.tagPropertyId); + expect(await tagValue(f.docs.a1, f.tagPropertyId)).toBe("Alpha"); + expect(await tagValue(f.docs.a2, f.tagPropertyId)).toBeUndefined(); + } + }); + it("clears a stale column value when the newly bound field is empty", async () => { const f = await seedRowUnion(); const db = getDb(); diff --git a/templates/content/actions/bind-content-database-source-field.ts b/templates/content/actions/bind-content-database-source-field.ts index ca61a862bd..c560a44c86 100644 --- a/templates/content/actions/bind-content-database-source-field.ts +++ b/templates/content/actions/bind-content-database-source-field.ts @@ -74,40 +74,92 @@ export default defineAction({ // ── Unbind ──────────────────────────────────────────────────────────── if (args.propertyId === null) { - if (field.propertyId) { - const sourceRows = await db - .select({ documentId: schema.contentDatabaseSourceRows.documentId }) - .from(schema.contentDatabaseSourceRows) - .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id)); - const sourceDocumentIds = sourceRows - .map((row) => row.documentId) - .filter((id): id is string => Boolean(id)); - if (sourceDocumentIds.length > 0) { - await db - .delete(schema.documentPropertyValues) - .where( - and( - eq(schema.documentPropertyValues.propertyId, field.propertyId), - inArray( - schema.documentPropertyValues.documentId, - sourceDocumentIds, + await db.transaction(async (tx) => { + const [lockedDatabase] = await tx + .update(schema.contentDatabases) + .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` }) + .where( + and( + eq(schema.contentDatabases.id, database.id), + eq(schema.contentDatabases.ownerEmail, database.ownerEmail), + isNull(schema.contentDatabases.deletedAt), + ), + ) + .returning({ id: schema.contentDatabases.id }); + if (!lockedDatabase) throw new Error("Database is no longer active."); + + const [lockedField] = await tx + .select() + .from(schema.contentDatabaseSourceFields) + .where(eq(schema.contentDatabaseSourceFields.id, field.id)); + if ( + !lockedField || + lockedField.sourceId !== source.id || + lockedField.mappingType === "title" || + lockedField.mappingType === "system" || + lockedField.writeOwner === "derived" + ) { + throw new Error( + "Source field changed or was deleted before it could be unbound.", + ); + } + if (lockedField.propertyId) { + const sourceRows = await tx + .select({ + documentId: schema.contentDatabaseSourceRows.documentId, + }) + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id)); + const sourceDocumentIds = sourceRows + .map((row) => row.documentId) + .filter((id): id is string => Boolean(id)); + if (sourceDocumentIds.length > 0) { + await tx + .delete(schema.documentPropertyValues) + .where( + and( + eq( + schema.documentPropertyValues.propertyId, + lockedField.propertyId, + ), + inArray( + schema.documentPropertyValues.documentId, + sourceDocumentIds, + ), ), - ), - ); + ); + } } - } - await db - .update(schema.contentDatabaseSourceFields) - .set({ - propertyId: null, - localFieldKey: field.sourceFieldKey, - updatedAt: now, - }) - .where(eq(schema.contentDatabaseSourceFields.id, field.id)); - await db - .update(schema.contentDatabaseSources) - .set({ updatedAt: now }) - .where(eq(schema.contentDatabaseSources.id, source.id)); + const [updatedField] = await tx + .update(schema.contentDatabaseSourceFields) + .set({ + propertyId: null, + localFieldKey: lockedField.sourceFieldKey, + updatedAt: now, + }) + .where(eq(schema.contentDatabaseSourceFields.id, lockedField.id)) + .returning({ id: schema.contentDatabaseSourceFields.id }); + if (!updatedField) { + throw new Error( + "Source field was deleted before its unbinding could be saved.", + ); + } + const [updatedSource] = await tx + .update(schema.contentDatabaseSources) + .set({ updatedAt: now }) + .where( + and( + eq(schema.contentDatabaseSources.id, source.id), + eq(schema.contentDatabaseSources.databaseId, database.id), + ), + ) + .returning({ id: schema.contentDatabaseSources.id }); + if (!updatedSource) { + throw new Error( + "Source was deleted before its field unbinding could be saved.", + ); + } + }); return getContentDatabaseResponse(database.id, { limit: 100, offset: 0 }); } diff --git a/templates/content/actions/delete-document.test.ts b/templates/content/actions/delete-document.test.ts index 40bdbf575f..b671f10d6f 100644 --- a/templates/content/actions/delete-document.test.ts +++ b/templates/content/actions/delete-document.test.ts @@ -181,6 +181,52 @@ describe("deleteDocumentRecursive", () => { expect(transactionDepth).toBe(0); }); + it("locks a row document's parent database without deleting that database", async () => { + selectRows.contentDatabaseItems = [ + { + id: "row-membership", + databaseId: "parent-database", + documentId: "row-document", + ownerEmail: "owner-a@example.com", + }, + ]; + selectRows.contentDatabases = [ + { + id: "parent-database", + documentId: "parent-database-document", + ownerEmail: "owner-a@example.com", + }, + ]; + let lockedParentDatabase = false; + db.update = (table: Record) => ({ + set: () => ({ + where: async (cond: unknown) => { + if ( + table === schema.contentDatabases && + matches( + { + id: "parent-database", + ownerEmail: "owner-a@example.com", + }, + cond, + ) + ) { + lockedParentDatabase = true; + } + return []; + }, + }), + }); + + await deleteDocumentRecursive(db, "row-document", "owner-a@example.com"); + + expect(lockedParentDatabase).toBe(true); + const parentDatabaseDeletes = deleteCalls.filter( + (call) => call.table === "contentDatabases", + ); + expect(parentDatabaseDeletes).toEqual([]); + }); + it("deletes document_comments rows for the document being deleted (n38)", async () => { await deleteDocumentRecursive(db, "doc-1", "owner-a@example.com"); diff --git a/templates/content/actions/delete-document.ts b/templates/content/actions/delete-document.ts index b0b26100d0..df7e70cd18 100644 --- a/templates/content/actions/delete-document.ts +++ b/templates/content/actions/delete-document.ts @@ -97,6 +97,28 @@ async function selectDatabaseItemDocuments( return ownedRows.map((row) => ({ documentId: row.id })); } +async function selectMembershipDatabaseIds( + db: ReturnType, + documentIds: string[], + ownerEmail: string, +) { + const rows: Array<{ databaseId: string }> = []; + for (const batch of chunks(documentIds, DELETE_BATCH_SIZE)) { + rows.push( + ...(await db + .select({ databaseId: schema.contentDatabaseItems.databaseId }) + .from(schema.contentDatabaseItems) + .where( + and( + inArray(schema.contentDatabaseItems.documentId, batch), + eq(schema.contentDatabaseItems.ownerEmail, ownerEmail), + ), + )), + ); + } + return [...new Set(rows.map((row) => row.databaseId))]; +} + async function collectDocumentSubtreeForDelete( db: ReturnType, rootId: string, @@ -150,14 +172,26 @@ async function collectDocumentSubtreeForDelete( frontier = [...next]; } + const collectedDocumentIds = [...documentIds]; + const collectedOwnedDatabaseIds = [...ownedDatabaseIds]; return { - documentIds: [...documentIds], - ownedDatabaseIds: [...ownedDatabaseIds], + documentIds: collectedDocumentIds, + ownedDatabaseIds: collectedOwnedDatabaseIds, + lockDatabaseIds: [ + ...new Set([ + ...collectedOwnedDatabaseIds, + ...(await selectMembershipDatabaseIds( + db, + collectedDocumentIds, + ownerEmail, + )), + ]), + ], }; } async function lockDatabasesAndRecollect< - T extends { ownedDatabaseIds: string[] }, + T extends { lockDatabaseIds: string[] }, >( db: ReturnType, ownerEmail: string, @@ -166,7 +200,7 @@ async function lockDatabasesAndRecollect< const lockedDatabaseIds = new Set(); let collected = await collect(); while (true) { - const unlockedDatabaseIds = collected.ownedDatabaseIds.filter( + const unlockedDatabaseIds = collected.lockDatabaseIds.filter( (databaseId) => !lockedDatabaseIds.has(databaseId), ); if (unlockedDatabaseIds.length === 0) return collected; @@ -651,6 +685,16 @@ async function deleteTrashedDocumentSubtreeInTransaction( return { documentIds: collectedDocumentIds, ownedDatabaseIds: collectedDatabaseIds, + lockDatabaseIds: [ + ...new Set([ + ...collectedDatabaseIds, + ...(await selectMembershipDatabaseIds( + db, + collectedDocumentIds, + ownerEmail, + )), + ]), + ], }; }, ); 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 99ec550156..90b3dca09d 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 @@ -524,6 +524,62 @@ describe("upsert-database-item-by-key", () => { 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, + }); + 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([]); + }); + 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(() => diff --git a/templates/content/actions/upsert-database-item-by-key.ts b/templates/content/actions/upsert-database-item-by-key.ts index ac3c3decd5..ec1d53c0c7 100644 --- a/templates/content/actions/upsert-database-item-by-key.ts +++ b/templates/content/actions/upsert-database-item-by-key.ts @@ -271,8 +271,10 @@ export default defineAction({ } } - const [transactionSourceManagedKey] = await tx - .select({ id: schema.contentDatabaseSourceFields.id }) + const transactionSourceManagedProperties = await tx + .select({ + propertyId: schema.contentDatabaseSourceFields.propertyId, + }) .from(schema.contentDatabaseSourceFields) .innerJoin( schema.contentDatabaseSources, @@ -284,16 +286,25 @@ export default defineAction({ .where( and( eq(schema.contentDatabaseSources.databaseId, databaseId), - eq( + inArray( schema.contentDatabaseSourceFields.propertyId, - keyPropertyId, + requestedPropertyIds, ), ), - ) - .limit(1); - if (transactionSourceManagedKey) { + ); + 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 "${keyDefinition.name}" cannot be used as a stable key.`, + `Property "${managedDefinition?.name ?? managedPropertyId}" is source-managed and cannot be written by this action.`, ); } From c06a920183af7742558ab868b491b5fd0420e0ff Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:27:18 -0400 Subject: [PATCH 13/13] fix(content): serialize stable-key receipt readback --- packages/core/src/db/index.ts | 1 + .../upsert-database-item-by-key.db.test.ts | 33 ++++++++++++ .../actions/upsert-database-item-by-key.ts | 50 +++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts index f4c4b08342..4d9e171f37 100644 --- a/packages/core/src/db/index.ts +++ b/packages/core/src/db/index.ts @@ -39,6 +39,7 @@ export { export { getDbExec, createDbExec, + getDatabaseUrl, getDialect, isLocalDatabase, isPostgres, 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 90b3dca09d..41f6c6f092 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 @@ -173,6 +173,39 @@ describe("upsert-database-item-by-key", () => { ).rejects.toThrow(); }); + 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(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"); + }); + it("advances updatedAt monotonically for an existing-row body projection", async () => { const { databaseId, propertyId } = await fixture(); const created = await asOwner(() => diff --git a/templates/content/actions/upsert-database-item-by-key.ts b/templates/content/actions/upsert-database-item-by-key.ts index ec1d53c0c7..3de2dae12b 100644 --- a/templates/content/actions/upsert-database-item-by-key.ts +++ b/templates/content/actions/upsert-database-item-by-key.ts @@ -1,5 +1,11 @@ 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"; @@ -42,6 +48,44 @@ const upsertSchema = z.object({ 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.", @@ -142,6 +186,10 @@ export default defineAction({ 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); @@ -760,5 +808,7 @@ export default defineAction({ keyValue, readback: { items: readback.items, pagination: readback.pagination }, }; + }, + ); }, });