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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions migrations/0036_deleted_records_cancel_pending_reviews.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
PRAGMA foreign_keys = ON;

-- Repair records deleted before this migration. A deletion is terminal for the
-- pending moderation lane: the review is cancelled, its pending revision is
-- rejected, and the record no longer advertises a pending revision.
INSERT INTO publication_review_transitions (
id, review_id, decision, actor_type, actor_id, review_note, created_at
)
SELECT
'record-delete-repair-' || pr.id,
pr.id,
'cancelled',
rd.actor_type,
rd.actor_id,
'Record deleted before review completed.',
rd.created_at
FROM publication_reviews pr
JOIN records r ON r.id = pr.record_id
JOIN record_deletion_transitions rd ON rd.record_id = r.id
WHERE r.deleted_at IS NOT NULL
AND pr.status = 'pending';

UPDATE record_revisions
SET state = 'rejected'
WHERE state = 'pending'
AND id IN (
SELECT pending_revision_id
FROM records
WHERE deleted_at IS NOT NULL
AND pending_revision_id IS NOT NULL
);

UPDATE records
SET pending_revision_id = NULL
WHERE deleted_at IS NOT NULL
AND pending_revision_id IS NOT NULL;

-- The original Slice 4 trigger made the record terminal but left an active
-- moderation review attached to a deleted record. Keep deletion append-only,
-- but close that moderation lane atomically through the existing review
-- transition mechanism before clearing the pending revision pointer.
DROP TRIGGER record_deletion_transitions_apply;

CREATE TRIGGER record_deletion_transitions_apply
AFTER INSERT ON record_deletion_transitions
BEGIN
INSERT INTO publication_review_transitions (
id, review_id, decision, actor_type, actor_id, review_note, created_at
)
SELECT
NEW.id || '-review-' || pr.id,
pr.id,
'cancelled',
NEW.actor_type,
NEW.actor_id,
'Record deleted before review completed.',
NEW.created_at
FROM publication_reviews pr
WHERE pr.record_id = NEW.record_id
AND pr.status = 'pending';

UPDATE record_revisions
SET state = 'rejected'
WHERE id = (
SELECT pending_revision_id
FROM records
WHERE id = NEW.record_id
)
AND state = 'pending';

UPDATE records
SET lifecycle_state = 'deleted',
pending_revision_id = NULL,
deleted_at = NEW.created_at,
updated_at = NEW.created_at,
version = version + 1
WHERE id = NEW.record_id
AND deleted_at IS NULL;
END;
53 changes: 40 additions & 13 deletions scripts/orbit-slice1-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1365,19 +1365,10 @@ let firstCredentialToken = '';
const deletedDirectPost = await postJson(
`/v1/mcp/grants/${encodeURIComponent(recipientGrant.grantId)}/records/${encodeURIComponent(directPostBody.record.id)}/delete`,
{ reason: 'v0.5.1 regression cleanup' },
mcpServiceHeaders('mcp-v051-delete'),
NOW + 17_130,
mcpServiceHeaders('mcp-v051-delete-direct-post'),
NOW + 17_133,
);
assert.equal(deletedDirectPost.status, 200, await deletedDirectPost.clone().text());
assert.equal((await deletedDirectPost.json() as { record: { status: string } }).record.status, 'deleted');
const deletedRecordDetail = await postJson(
`/v1/mcp/grants/${encodeURIComponent(recipientGrant.grantId)}/agent/records/${encodeURIComponent(directPostBody.record.id)}`,
{},
mcpServiceHeaders(),
NOW + 17_131,
);
assert.equal(deletedRecordDetail.status, 200, await deletedRecordDetail.clone().text());
assert.equal((await deletedRecordDetail.json() as { record: { lifecycleState: string } }).record.lifecycleState, 'deleted');

const initialUnread = await postJson(
`/v1/mcp/grants/${encodeURIComponent(recipientGrant.grantId)}/direct-messages/unread-count`,
Expand Down Expand Up @@ -1526,11 +1517,47 @@ let firstCredentialToken = '';
);
assert.equal(revokedRecipientGrant.status, 200, await revokedRecipientGrant.clone().text());

const pendingDeleteReply = await postJson(
`/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records/${encodeURIComponent(createdPostBody.record.id)}/replies`,
{ bodyMarkdown: 'MCP v0.5.1 pending delete cleanup testi.', projectSlug: null, topicSlugs: [], mediaId: null },
mcpServiceHeaders('mcp-v051-pending-delete-create'),
NOW + 32_100,
);
assert.equal(pendingDeleteReply.status, 202, await pendingDeleteReply.clone().text());
const pendingDeleteReplyBody = await pendingDeleteReply.json() as { record: { id: string } };

const deletedPendingReply = await postJson(
`/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records/${encodeURIComponent(pendingDeleteReplyBody.record.id)}/delete`,
{ reason: 'v0.5.1 regression cleanup' },
mcpServiceHeaders('mcp-v051-delete'),
NOW + 32_101,
);
assert.equal(deletedPendingReply.status, 200, await deletedPendingReply.clone().text());
assert.equal((await deletedPendingReply.json() as { record: { status: string } }).record.status, 'deleted');
const deletedRecordDetail = await postJson(
`/v1/mcp/grants/${encodeURIComponent(full.grantId)}/agent/records/${encodeURIComponent(pendingDeleteReplyBody.record.id)}`,
{},
mcpServiceHeaders(),
NOW + 32_102,
);
assert.equal(deletedRecordDetail.status, 200, await deletedRecordDetail.clone().text());
const deletedRecordDetailBody = await deletedRecordDetail.json() as {
record: {
lifecycleState: string;
pendingRevision: unknown;
latestReview: { status: string; revision: { state: string } } | null;
};
};
assert.equal(deletedRecordDetailBody.record.lifecycleState, 'deleted');
assert.equal(deletedRecordDetailBody.record.pendingRevision, null);
assert.equal(deletedRecordDetailBody.record.latestReview?.status, 'cancelled');
assert.equal(deletedRecordDetailBody.record.latestReview?.revision.state, 'rejected');

const revokedMcpWriteGrant = await postJson(
`/v1/mcp/authorizations/${encodeURIComponent(full.grantId)}/revoke`,
{},
authenticatedHeaders(sponsorCookies, true),
NOW + 17_002,
NOW + 32_103,
);
assert.equal(
revokedMcpWriteGrant.status,
Expand All @@ -1542,7 +1569,7 @@ let firstCredentialToken = '';
`/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records`,
{ ...mcpWritePostBody, bodyMarkdown: 'İptalden sonra yazılamaz.' },
mcpServiceHeaders('mcp-after-revoke'),
NOW + 17_003,
NOW + 32_104,
);
assert.equal(afterRevoke.status, 401);
const afterRevokeBody = await afterRevoke.json() as { error: { code: string } };
Expand Down