From 500164f3b9678a9c818c67384d26dbe654db5941 Mon Sep 17 00:00:00 2001 From: AriaEdo Date: Thu, 10 Sep 2026 20:53:00 +0700 Subject: [PATCH 1/7] fix(twins): add one-off scripts to fix twins-post migration data Reconnects orphaned topics relations, republishes posts missing relations on their published version, and grants public read access so the twins-in-the-loop site can query twins-post/topic. Co-Authored-By: Claude Sonnet 5 --- scripts/fix-twins-post-topics.js | 78 +++++++++++++++++++++++ scripts/grant-twins-public-permissions.js | 57 +++++++++++++++++ scripts/republish-twins-post.js | 41 ++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 scripts/fix-twins-post-topics.js create mode 100644 scripts/grant-twins-public-permissions.js create mode 100644 scripts/republish-twins-post.js diff --git a/scripts/fix-twins-post-topics.js b/scripts/fix-twins-post-topics.js new file mode 100644 index 0000000..2e3e389 --- /dev/null +++ b/scripts/fix-twins-post-topics.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * Fix: migrate-twins-post.js passed a bare documentId array for the `topics` + * many-to-many relation, which Strapi's document service silently mishandled + * (orphaned link rows with no twins_post_id). Reconnects each post to its + * topics using `{ connect: [...] }`, which links correctly. + * + * Topic slugs below are recovered from the original post frontmatter + * (twins-in-the-loop git history), since they were never persisted. + * + * Usage: node scripts/fix-twins-post-topics.js + */ + +const POST_TOPICS = { + 'ai-and-datacenter-conversations': ['ai', 'data-centers'], + 'arm-owns-the-datacenter': ['hardware', 'data-centers'], + 'arms-next-chapter': ['hardware'], + 'ask-me-about-the-weather': ['ai'], + 'i-invented-bare-metal': ['hardware'], + 'implications-of-geopolitical-decoupling': ['infrastructure'], + 'impossible-to-buy-datacenter-space': ['data-centers'], + 'live-coding-a-cli-in-rust': ['infrastructure'], + 'nobody-cares-about-gandalf': ['infrastructure'], + 'on-device-ai': ['ai', 'hardware'], + 'reading-500-incident-reports': ['infrastructure'], + 'reduced-deploy-times': ['infrastructure'], + 'simplify-then-add-lightness': ['hardware'], + 'taking-writing-lessons': ['ai'], + 'zero-downtime-schema-migrations': ['infrastructure'], +}; + +async function fixTopics(app) { + // Clear the orphaned link rows left by the original migration. + await app.db.connection('twins_blogs_topics_lnk').del(); + + for (const [slug, topicSlugs] of Object.entries(POST_TOPICS)) { + const topics = await app.documents('api::topic.topic').findMany({ + filters: { slug: { $in: topicSlugs } }, + }); + const topicDocumentIds = topics.map((topic) => topic.documentId); + + for (const status of ['draft', 'published']) { + const post = await app.documents('api::twins-post.twins-post').findFirst({ + filters: { slug }, + status, + }); + if (!post) { + console.warn(`No ${status} version found for "${slug}"`); + continue; + } + await app.documents('api::twins-post.twins-post').update({ + documentId: post.documentId, + data: { topics: { connect: topicDocumentIds } }, + status, + }); + } + console.log(`Linked "${slug}" to [${topicSlugs.join(', ')}]`); + } +} + +async function main() { + const { createStrapi, compileStrapi } = require('@strapi/strapi'); + + const appContext = await compileStrapi(); + const app = await createStrapi(appContext).load(); + + app.log.level = 'error'; + + await fixTopics(app); + await app.destroy(); + + process.exit(0); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/grant-twins-public-permissions.js b/scripts/grant-twins-public-permissions.js new file mode 100644 index 0000000..59b463f --- /dev/null +++ b/scripts/grant-twins-public-permissions.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * Grant the public role read access to twins-post/topic so the + * twins-in-the-loop site can fetch published posts via GraphQL without a token. + * Idempotent: skips any action that's already granted. + * + * Usage: node scripts/grant-twins-public-permissions.js + */ + +const ACTIONS = [ + 'api::twins-post.twins-post.find', + 'api::twins-post.twins-post.findOne', + 'api::topic.topic.find', + 'api::topic.topic.findOne', +]; + +async function grantPermissions(app) { + const publicRole = await app.query('plugin::users-permissions.role').findOne({ + where: { type: 'public' }, + }); + if (!publicRole) { + throw new Error('Public role not found'); + } + + for (const action of ACTIONS) { + const existing = await app.query('plugin::users-permissions.permission').findOne({ + where: { action, role: publicRole.id }, + }); + if (existing) { + console.log(`Skipping "${action}" (already granted)`); + continue; + } + await app.query('plugin::users-permissions.permission').create({ + data: { action, role: publicRole.id }, + }); + console.log(`Granted "${action}"`); + } +} + +async function main() { + const { createStrapi, compileStrapi } = require('@strapi/strapi'); + + const appContext = await compileStrapi(); + const app = await createStrapi(appContext).load(); + + app.log.level = 'error'; + + await grantPermissions(app); + await app.destroy(); + + process.exit(0); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/republish-twins-post.js b/scripts/republish-twins-post.js new file mode 100644 index 0000000..0251ba7 --- /dev/null +++ b/scripts/republish-twins-post.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +/** + * Fix: migrate-twins-post.js created entries with status "published", but the + * published version ended up without its topics/blocks/cover relations + * (only the draft version got them). Republishing from the current draft + * copies its content into the published version. + * + * Usage: node scripts/republish-twins-post.js + */ + +async function republishAll(app) { + const drafts = await app.documents('api::twins-post.twins-post').findMany({ + status: 'draft', + }); + + for (const draft of drafts) { + await app.documents('api::twins-post.twins-post').publish({ + documentId: draft.documentId, + }); + console.log(`Republished "${draft.slug}"`); + } +} + +async function main() { + const { createStrapi, compileStrapi } = require('@strapi/strapi'); + + const appContext = await compileStrapi(); + const app = await createStrapi(appContext).load(); + + app.log.level = 'error'; + + await republishAll(app); + await app.destroy(); + + process.exit(0); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); From b16183e6fe30649712d646fc3dfee3daad2cbeb1 Mon Sep 17 00:00:00 2001 From: Edo Date: Fri, 11 Sep 2026 00:44:30 +0700 Subject: [PATCH 2/7] Refactor topic document ID retrieval logic Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Edo --- scripts/fix-twins-post-topics.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/fix-twins-post-topics.js b/scripts/fix-twins-post-topics.js index 2e3e389..0afb5fa 100644 --- a/scripts/fix-twins-post-topics.js +++ b/scripts/fix-twins-post-topics.js @@ -37,8 +37,14 @@ async function fixTopics(app) { const topics = await app.documents('api::topic.topic').findMany({ filters: { slug: { $in: topicSlugs } }, }); - const topicDocumentIds = topics.map((topic) => topic.documentId); + const topicsBySlug = new Map(topics.map((topic) => [topic.slug, topic])); + const missingSlugs = topicSlugs.filter((s) => !topicsBySlug.has(s)); + if (missingSlugs.length) { + throw new Error(`Missing topic(s) for "${slug}": ${missingSlugs.join(', ')}`); + } + + const topicDocumentIds = topicSlugs.map((s) => topicsBySlug.get(s).documentId); for (const status of ['draft', 'published']) { const post = await app.documents('api::twins-post.twins-post').findFirst({ filters: { slug }, From dd40f05737cca5b415b29b9edf5cf7fe4644a3cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:45:28 +0000 Subject: [PATCH 3/7] fix: Resolve orphaned topic cleanup Co-authored-by: AriaEdo <1716540+AriaEdo@users.noreply.github.com> --- scripts/fix-twins-post-topics.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/fix-twins-post-topics.js b/scripts/fix-twins-post-topics.js index 0afb5fa..ec9f639 100644 --- a/scripts/fix-twins-post-topics.js +++ b/scripts/fix-twins-post-topics.js @@ -30,8 +30,16 @@ const POST_TOPICS = { }; async function fixTopics(app) { - // Clear the orphaned link rows left by the original migration. - await app.db.connection('twins_blogs_topics_lnk').del(); + const topicsAttribute = app.db.metadata.get('api::twins-post.twins-post').attributes.topics; + const joinTableName = topicsAttribute?.joinTable?.name; + const postJoinColumn = topicsAttribute?.joinTable?.joinColumn?.name; + + if (!joinTableName || !postJoinColumn) { + throw new Error('Could not resolve twins-post topics join table metadata'); + } + + // Clear only the orphaned link rows left by the original migration. + await app.db.connection(joinTableName).whereNull(postJoinColumn).del(); for (const [slug, topicSlugs] of Object.entries(POST_TOPICS)) { const topics = await app.documents('api::topic.topic').findMany({ From 75097ba805dde08855b8a96c5650e388202b6517 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:45:38 +0000 Subject: [PATCH 4/7] fix: Use article UID for republish script Co-authored-by: AriaEdo <1716540+AriaEdo@users.noreply.github.com> --- scripts/republish-twins-post.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/republish-twins-post.js b/scripts/republish-twins-post.js index 0251ba7..a6049ca 100644 --- a/scripts/republish-twins-post.js +++ b/scripts/republish-twins-post.js @@ -8,13 +8,15 @@ * Usage: node scripts/republish-twins-post.js */ +const CONTENT_TYPE_UID = 'api::article.article'; + async function republishAll(app) { - const drafts = await app.documents('api::twins-post.twins-post').findMany({ + const drafts = await app.documents(CONTENT_TYPE_UID).findMany({ status: 'draft', }); for (const draft of drafts) { - await app.documents('api::twins-post.twins-post').publish({ + await app.documents(CONTENT_TYPE_UID).publish({ documentId: draft.documentId, }); console.log(`Republished "${draft.slug}"`); From fba58ceeaaf025ae8f53816c184eb7e28653424a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:46:59 +0000 Subject: [PATCH 5/7] fix(twins): validate permission content type uids Co-authored-by: AriaEdo <1716540+AriaEdo@users.noreply.github.com> --- scripts/grant-twins-public-permissions.js | 31 +++++++++++++++-------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/scripts/grant-twins-public-permissions.js b/scripts/grant-twins-public-permissions.js index 59b463f..ff4fb5c 100644 --- a/scripts/grant-twins-public-permissions.js +++ b/scripts/grant-twins-public-permissions.js @@ -1,18 +1,29 @@ #!/usr/bin/env node /** - * Grant the public role read access to twins-post/topic so the - * twins-in-the-loop site can fetch published posts via GraphQL without a token. + * Grant the public role read access to the specified content types. * Idempotent: skips any action that's already granted. * - * Usage: node scripts/grant-twins-public-permissions.js + * Usage: + * node scripts/grant-twins-public-permissions.js api::article.article api::category.category */ -const ACTIONS = [ - 'api::twins-post.twins-post.find', - 'api::twins-post.twins-post.findOne', - 'api::topic.topic.find', - 'api::topic.topic.findOne', -]; +const contentTypeUids = process.argv.slice(2); + +function getActions(app) { + if (contentTypeUids.length === 0) { + throw new Error( + 'Provide one or more content-type UIDs, e.g. api::article.article api::category.category' + ); + } + + return contentTypeUids.flatMap((uid) => { + if (!app.contentTypes[uid]) { + throw new Error(`Content type "${uid}" is not registered in this project`); + } + + return [`${uid}.find`, `${uid}.findOne`]; + }); +} async function grantPermissions(app) { const publicRole = await app.query('plugin::users-permissions.role').findOne({ @@ -22,7 +33,7 @@ async function grantPermissions(app) { throw new Error('Public role not found'); } - for (const action of ACTIONS) { + for (const action of getActions(app)) { const existing = await app.query('plugin::users-permissions.permission').findOne({ where: { action, role: publicRole.id }, }); From 1d8977a7c4f373d69b647ce42b2223ae0493b513 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:57:49 +0000 Subject: [PATCH 6/7] fix(article): skip draft-only republish entries Co-authored-by: AriaEdo <1716540+AriaEdo@users.noreply.github.com> --- scripts/republish-twins-post.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/republish-twins-post.js b/scripts/republish-twins-post.js index a6049ca..610775f 100644 --- a/scripts/republish-twins-post.js +++ b/scripts/republish-twins-post.js @@ -16,6 +16,16 @@ async function republishAll(app) { }); for (const draft of drafts) { + const published = await app.documents(CONTENT_TYPE_UID).findFirst({ + documentId: draft.documentId, + status: 'published', + }); + + if (!published) { + console.log(`Skipping "${draft.slug}" (no published version to repair)`); + continue; + } + await app.documents(CONTENT_TYPE_UID).publish({ documentId: draft.documentId, }); From 584eb1da3ad6af4f3c8f59f146c89d806e726ba1 Mon Sep 17 00:00:00 2001 From: AriaEdo Date: Fri, 11 Sep 2026 12:37:30 +0700 Subject: [PATCH 7/7] fix(twins): correct content-type UID in republish-twins-post script CONTENT_TYPE_UID was left as api::article.article, a content type that doesn't exist in this project. Should target api::twins-post.twins-post, matching the migration this script repairs. Co-Authored-By: Claude Sonnet 5 --- scripts/republish-twins-post.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/republish-twins-post.js b/scripts/republish-twins-post.js index 610775f..cac0200 100644 --- a/scripts/republish-twins-post.js +++ b/scripts/republish-twins-post.js @@ -8,7 +8,7 @@ * Usage: node scripts/republish-twins-post.js */ -const CONTENT_TYPE_UID = 'api::article.article'; +const CONTENT_TYPE_UID = 'api::twins-post.twins-post'; async function republishAll(app) { const drafts = await app.documents(CONTENT_TYPE_UID).findMany({