Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
684b22a
fix: preserve Content database property mutations
3mdistal Aug 21, 2026
cc36089
Merge remote-tracking branch 'origin/main'
3mdistal Aug 21, 2026
64b4004
Merge remote-tracking branch 'origin/main'
3mdistal Aug 21, 2026
74eb67f
fix: normalize Content AI property entries
3mdistal Aug 21, 2026
fd85e17
fix: canonicalize Content mutation inputs
3mdistal Aug 21, 2026
7534440
test: require successful Content mutation evals
3mdistal Aug 21, 2026
f975921
Merge remote-tracking branch 'origin/main' into codex/content-ai-prop…
3mdistal Aug 21, 2026
0e7b09d
chore: publish branch work in packages/core, templates/content (7 files)
3mdistal Aug 21, 2026
d016299
fix: make Content database mutations agent-safe
3mdistal Aug 24, 2026
3097054
fix: address review feedback on Content mutation parity
builderio-bot Aug 25, 2026
55eb6fd
fix: require successful discovery before accepting the property prese…
builderio-bot Aug 25, 2026
9e76078
fix: preserve Content mutation replay compatibility
3mdistal Aug 25, 2026
7632a42
docs(content): preserve planning and suggested-edits artifacts
3mdistal Sep 3, 2026
7c409e8
feat(content): clarify page and collection creation
3mdistal Sep 22, 2026
257ba84
Merge remote-tracking branch 'origin/main' into t3code/content-page-c…
3mdistal Sep 22, 2026
9d97b26
fix(content): remove unused editor icon import
3mdistal Sep 22, 2026
38521e6
fix(content): separate workspace and content creation
3mdistal Sep 22, 2026
b131936
fix(content): guard collection conversion and insert full-page blocks
3mdistal Sep 23, 2026
23407e4
fix(content): use collection-neutral creation feedback
3mdistal Sep 23, 2026
2feac6f
Merge origin/main into Content creation PR
3mdistal Sep 23, 2026
6e7bbc8
fix(slides): scope design-system count refresh to editors
3mdistal Sep 23, 2026
057753b
fix(content): select workspace for collection creation
3mdistal Sep 23, 2026
7978324
Merge main into Content creation branch
3mdistal Sep 23, 2026
9b5197f
Fix Content creation sequencing and failed slash cleanup
3mdistal Sep 23, 2026
12c04d0
Merge main into Content creation branch
3mdistal Sep 23, 2026
7cad4dd
Preserve slash input and recover failed workspace selection
3mdistal Sep 23, 2026
c50ad9d
Merge main into Content creation branch
3mdistal Sep 23, 2026
dc308d2
Allow safe creator rollback for failed slash creation
3mdistal Sep 23, 2026
329b0a4
Make full-page slash collection creation replayable
3mdistal Sep 23, 2026
f5ba167
test(content): cover slash rollback in parity matrix
3mdistal Sep 23, 2026
3a9cbd5
fix(content): recover slash creations after lost responses
3mdistal Sep 23, 2026
0481e82
test(content): prove recoverable inline collection IDs
3mdistal Sep 23, 2026
905da5c
fix(content): recover slash page after lost creation response
3mdistal Sep 23, 2026
c090183
style(content): format workspace selection assertion
3mdistal Sep 23, 2026
5ff23d9
test(slides): preserve access predicate in design-system mock
3mdistal Sep 23, 2026
3b3db35
Merge remote-tracking branch 'origin/main' into t3code/content-page-c…
3mdistal Sep 23, 2026
3dc4405
test(slides): match scoped hydration predicate order
3mdistal Sep 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion templates/content/actions/blocks-seeding.db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ let propertyUtils: typeof import("./_property-utils.js");
let identityUtils: typeof import("./_blocks-field-identity.js");
let databaseUtils: typeof import("./_database-utils.js");
let createInlineContentDatabaseAction: typeof import("./create-inline-content-database.js").default;
let rollbackCreatedSlashDocumentAction: typeof import("./rollback-created-slash-document.js").default;
let updateDocumentAction: typeof import("./update-document.js").default;
let editDocumentAction: typeof import("./edit-document.js").default;
let documentRevisionToken: typeof import("./_document-edit-mutation.js").documentRevisionToken;
Expand All @@ -64,6 +65,9 @@ beforeAll(async () => {
createInlineContentDatabaseAction = (
await import("./create-inline-content-database.js")
).default;
rollbackCreatedSlashDocumentAction = (
await import("./rollback-created-slash-document.js")
).default;
updateDocumentAction = (await import("./update-document.js")).default;
editDocumentAction = (await import("./edit-document.js")).default;
({ documentRevisionToken } = await import("./_document-edit-mutation.js"));
Expand Down Expand Up @@ -408,6 +412,8 @@ describe("create-inline-content-database", () => {
const db = getDb();
const now = new Date().toISOString();
const hostDocumentId = `host_inline_${++counter}`;
const newDocumentId = `inline_document_${counter}`;
const ownerBlockId = `inline-database-${counter}`;
await db.insert(schema.documents).values({
id: hostDocumentId,
ownerEmail: OWNER,
Expand All @@ -421,13 +427,16 @@ describe("create-inline-content-database", () => {
createInlineContentDatabaseAction.run({
hostDocumentId,
title: "Inline tasks",
newDocumentId,
ownerBlockId,
}),
);

expect(result.database.title).toBe("Inline tasks");
expect(result.block.databaseId).toBe(result.database.id);
expect(result.block.databaseDocumentId).toBe(result.database.documentId);
expect(result.block.ownerBlockId).toMatch(/^inline-database-/);
expect(result.block.databaseDocumentId).toBe(newDocumentId);
expect(result.block.ownerBlockId).toBe(ownerBlockId);

const [database] = await db
.select()
Expand All @@ -441,6 +450,27 @@ describe("create-inline-content-database", () => {
.from(schema.documents)
.where(eq(schema.documents.id, result.database.documentId));
expect(databaseDocument.parentId).toBe(hostDocumentId);

const rolledBack = await runWithRequestContext({ userEmail: OWNER }, () =>
rollbackCreatedSlashDocumentAction.run({
id: newDocumentId,
parentId: hostDocumentId,
}),
);
expect(rolledBack.disposition).toBe("trashed");
const [trashedDocument] = await db
.select({ trashedAt: schema.documents.trashedAt })
.from(schema.documents)
.where(eq(schema.documents.id, newDocumentId));
expect(trashedDocument.trashedAt).not.toBeNull();

const absent = await runWithRequestContext({ userEmail: OWNER }, () =>
rollbackCreatedSlashDocumentAction.run({
id: `missing_inline_${counter}`,
parentId: hostDocumentId,
}),
);
expect(absent.disposition).toBe("absent");
});
});

Expand Down
7 changes: 6 additions & 1 deletion templates/content/actions/create-inline-content-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,18 @@ export default defineAction({
hostDocumentId: z.string().describe("Host page document ID"),
title: z.string().optional().describe("Collection title"),
description: z.string().optional().describe("Stable collection guidance"),
newDocumentId: z.string().min(1).optional(),
ownerBlockId: z.string().min(1).optional(),
}),
run: async ({
hostDocumentId,
title,
description,
newDocumentId,
ownerBlockId: requestedOwnerBlockId,
}): Promise<CreateInlineDatabaseResponse> => {
const db = getDb();
const ownerBlockId = createInlineDatabaseBlockId();
const ownerBlockId = requestedOwnerBlockId ?? createInlineDatabaseBlockId();
let databaseId: string | null = null;
let databaseDocumentId: string | null = null;
const spaceId = await resolveContentDatabaseSpace(
Expand All @@ -45,6 +49,7 @@ export default defineAction({
parentId: hostDocumentId,
title: databaseTitleForPage(title),
description,
newDocumentId,
},
{ db: tx, spaceId },
);
Expand Down
55 changes: 55 additions & 0 deletions templates/content/actions/rollback-created-slash-document.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";

import { assertSlashRollbackEligible } from "./rollback-created-slash-document";

const eligible = {
actor: "editor@example.com",
child: {
id: "new-page",
createdBy: "editor@example.com",
createdAt: "2026-09-23T18:00:00.000Z",
updatedAt: "2026-09-23T18:00:00.000Z",
content: "",
parentId: "host-page",
trashedAt: null,
},
parentId: "host-page",
parentContent: "Existing parent text",
ownerBlockId: null,
hasChildren: false,
hasDatabaseItems: false,
now: Date.parse("2026-09-23T18:01:00.000Z"),
};

describe("failed slash creation rollback eligibility", () => {
it("allows the creator to remove an unlinked, unchanged child", () => {
expect(() => assertSlashRollbackEligible(eligible)).not.toThrow();
});

it("rejects a different editor and an already linked child", () => {
expect(() =>
assertSlashRollbackEligible({ ...eligible, actor: "other@example.com" }),
).toThrow("Only the creator");
expect(() =>
assertSlashRollbackEligible({
...eligible,
parentContent: 'A reference to id="new-page"',
}),
).toThrow("already linked");
});

it("rejects changed or stale resources", () => {
expect(() =>
assertSlashRollbackEligible({
...eligible,
child: { ...eligible.child, content: "new work" },
}),
).toThrow("changed");
expect(() =>
assertSlashRollbackEligible({
...eligible,
now: Date.parse("2026-09-23T18:06:00.000Z"),
}),
).toThrow("changed");
});
});
188 changes: 188 additions & 0 deletions templates/content/actions/rollback-created-slash-document.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { ActionContractError, defineAction } from "@agent-native/core/action";
import { writeAppState } from "@agent-native/core/application-state";
import { ForbiddenError } from "@agent-native/core/sharing";
import { and, eq, isNull } from "drizzle-orm";
import { z } from "zod";

import { getDb, schema } from "../server/db/index.js";
import { requireDocumentRequestActor } from "../server/lib/document-attribution.js";
import { assertDocumentMutationAccess } from "./_document-mutation-access.js";
import {
lockDatabasesForTrash,
trashDocumentSubtree,
} from "./delete-document.js";

const ROLLBACK_WINDOW_MS = 5 * 60 * 1000;

export function assertSlashRollbackEligible(args: {
actor: string;
child: {
id: string;
createdBy: string | null;
createdAt: string;
updatedAt: string;
content: string;
parentId: string | null;
trashedAt: string | null;
};
parentId: string;
parentContent: string;
ownerBlockId?: string | null;
hasChildren: boolean;
hasDatabaseItems: boolean;
now: number;
}) {
if (args.child.createdBy?.toLowerCase() !== args.actor) {
throw new ForbiddenError("Only the creator can roll back this new page.");
}
const createdAt = Date.parse(args.child.createdAt);
if (
args.child.parentId !== args.parentId ||
args.child.trashedAt ||
!Number.isFinite(createdAt) ||
args.now < createdAt ||
args.now - createdAt > ROLLBACK_WINDOW_MS ||
args.child.updatedAt !== args.child.createdAt ||
args.child.content !== "" ||
args.hasChildren ||
args.hasDatabaseItems ||
args.parentContent.includes(args.child.id)
) {
throw new ActionContractError(
"The new page changed or is already linked; it cannot be rolled back.",
{ errorCode: "SLASH_ROLLBACK_NOT_EMPTY", statusCode: 409 },
);
}
if (args.ownerBlockId && args.parentContent.includes(args.ownerBlockId)) {
throw new ActionContractError(
"The new collection is already linked; it cannot be rolled back.",
{ errorCode: "SLASH_ROLLBACK_LINKED", statusCode: 409 },
);
}
}

export default defineAction({
description:
"Move an unchanged page or collection created by this caller's failed slash insertion to Trash; requires editor access to its parent.",
schema: z
.object({
id: z.string().min(1).describe("New page or collection document ID"),
parentId: z.string().min(1).describe("Page where slash creation began"),
})
.strict(),
run: async ({ id, parentId }, context) => {
const actor = requireDocumentRequestActor(context);
const parentAccess = await assertDocumentMutationAccess(
parentId,
"editor",
"parentId",
);
const ownerEmail = parentAccess.resource.ownerEmail as string;
const db = getDb();
const deletedIds = await db.transaction(async (transaction) => {
const tx = transaction as unknown as ReturnType<typeof getDb>;
const [parent] = await tx
.select({ content: schema.documents.content })
.from(schema.documents)
.where(
and(
eq(schema.documents.id, parentId),
eq(schema.documents.ownerEmail, ownerEmail),
isNull(schema.documents.trashedAt),
),
)
.for("update");
const [child] = await tx
.select({
createdBy: schema.documents.createdBy,
createdAt: schema.documents.createdAt,
updatedAt: schema.documents.updatedAt,
content: schema.documents.content,
parentId: schema.documents.parentId,
trashedAt: schema.documents.trashedAt,
})
.from(schema.documents)
.where(
and(
eq(schema.documents.id, id),
eq(schema.documents.ownerEmail, ownerEmail),
),
)
.for("update");
if (!parent) {
throw new ActionContractError(
"The new page is unavailable for rollback.",
{
errorCode: "SLASH_ROLLBACK_UNAVAILABLE",
statusCode: 409,
},
);
}
if (!child) return [];
const [database] = await tx
.select({
id: schema.contentDatabases.id,
ownerBlockId: schema.contentDatabases.ownerBlockId,
systemRole: schema.contentDatabases.systemRole,
})
.from(schema.contentDatabases)
.where(
and(
eq(schema.contentDatabases.documentId, id),
eq(schema.contentDatabases.ownerEmail, ownerEmail),
),
)
.limit(1);
const [childDocument] = await tx
.select({ id: schema.documents.id })
.from(schema.documents)
.where(
and(
eq(schema.documents.parentId, id),
eq(schema.documents.ownerEmail, ownerEmail),
isNull(schema.documents.trashedAt),
),
)
.limit(1);
const [databaseItem] = database
? await tx
.select({ id: schema.contentDatabaseItems.id })
.from(schema.contentDatabaseItems)
.where(
and(
eq(schema.contentDatabaseItems.databaseId, database.id),
eq(schema.contentDatabaseItems.ownerEmail, ownerEmail),
),
)
.limit(1)
: [];
assertSlashRollbackEligible({
actor,
child: { ...child, id },
parentId,
parentContent: parent.content,
ownerBlockId: database?.ownerBlockId,
hasChildren: !!childDocument,
hasDatabaseItems: !!databaseItem || !!database?.systemRole,
now: Date.now(),
});
const lockedDatabaseIds = await lockDatabasesForTrash(tx, id, ownerEmail);
return trashDocumentSubtree(
tx,
id,
ownerEmail,
undefined,
lockedDatabaseIds,
);
});
if (deletedIds.length > 0) {
await writeAppState("refresh-signal", { ts: Date.now() });
}
return {
success: true,
id,
disposition: deletedIds.length > 0 ? "trashed" : "absent",
deletedIds,
};
},
});
Loading
Loading