@strapi/database 5.54.0 → 5.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,80 +13,155 @@ function getBatchSize(trx, defaultSize = 1000) {
13
13
  ].includes(client);
14
14
  return isSQLite ? Math.min(defaultSize, 250) : defaultSize;
15
15
  }
16
- const QUERIES = {
17
- async postgres (knex, params) {
18
- const res = await knex.raw(`
19
- SELECT :tableName:.id as id, string_agg(DISTINCT :inverseJoinColumn:::character varying, ',') as other_ids
20
- FROM :tableName:
21
- LEFT JOIN :joinTableName: ON :tableName:.id = :joinTableName:.:joinColumn:
22
- WHERE :tableName:.document_id IS NULL
23
- GROUP BY :tableName:.id, :joinTableName:.:joinColumn:
24
- LIMIT 1;
25
- `, params);
26
- return res.rows;
27
- },
28
- async mysql (knex, params) {
29
- const [res] = await knex.raw(`
30
- SELECT :tableName:.id as id, group_concat(DISTINCT :inverseJoinColumn:) as other_ids
31
- FROM :tableName:
32
- LEFT JOIN :joinTableName: ON :tableName:.id = :joinTableName:.:joinColumn:
33
- WHERE :tableName:.document_id IS NULL
34
- GROUP BY :tableName:.id, :joinTableName:.:joinColumn:
35
- LIMIT 1;
36
- `, params);
37
- return res;
38
- },
39
- async sqlite (knex, params) {
40
- return knex.raw(`
41
- SELECT :tableName:.id as id, group_concat(DISTINCT :inverseJoinColumn:) as other_ids
42
- FROM :tableName:
43
- LEFT JOIN :joinTableName: ON :tableName:.id = :joinTableName:.:joinColumn:
44
- WHERE :tableName:.document_id IS NULL
45
- GROUP BY :joinTableName:.:joinColumn:
46
- LIMIT 1;
47
- `, params);
16
+ // Union-find (disjoint-set) helpers used to cluster localization-linked rows in memory.
17
+ // Path-compressing `find` keeps repeated lookups close to O(1) amortized.
18
+ const find = (parent, start)=>{
19
+ let current = start;
20
+ while(parent.get(current) !== current){
21
+ parent.set(current, parent.get(parent.get(current)));
22
+ current = parent.get(current);
48
23
  }
24
+ return current;
49
25
  };
50
- const getNextIdsToCreateDocumentId = async (db, knex, { joinColumn, inverseJoinColumn, tableName, joinTableName })=>{
51
- const res = await QUERIES[db.dialect.client](knex, {
52
- joinColumn,
53
- inverseJoinColumn,
54
- tableName,
55
- joinTableName
56
- });
57
- if (res.length > 0) {
58
- const row = res[0];
59
- const otherIds = row.other_ids ? row.other_ids.split(',').map((v)=>parseInt(v, 10)) : [];
60
- return [
61
- row.id,
62
- ...otherIds
63
- ];
26
+ const union = (parent, a, b)=>{
27
+ const rootA = find(parent, a);
28
+ const rootB = find(parent, b);
29
+ if (rootA !== rootB) {
30
+ parent.set(rootA, rootB);
64
31
  }
65
- return [];
66
32
  };
67
33
  // Migrate document ids for tables that have localizations
34
+ //
35
+ // This clusters rows in memory (via union-find) instead of discovering one cluster at a
36
+ // time through a `LIMIT 1` query. The previous implementation re-ran a query that scans
37
+ // the whole table (filtered by `document_id IS NULL`, a column with no index at this point
38
+ // in the migration) once per cluster, which is roughly O(n) discovery queries each doing
39
+ // an O(n) scan — effectively O(n^2) for a table with n rows. For tables with many rows
40
+ // and/or many locales this could take hours or longer without making visible progress.
41
+ // See https://github.com/strapi/strapi/issues/23812, 23517, 25231.
42
+ //
43
+ // This also fixes a correctness gap: the original query only follows *direct* links
44
+ // (one hop), so a chain of locale links (A<->B, B<->C, but no direct A<->C row) could be
45
+ // split across two document ids instead of clustered together. Union-find naturally
46
+ // follows transitive links. See https://github.com/strapi/strapi/issues/20948, 24544.
47
+ //
48
+ // Retry-safety: a previous run can crash between clusters, leaving some rows in a
49
+ // locale group already assigned a document_id while their siblings are still NULL. On
50
+ // retry those NULL rows must adopt the sibling's existing document_id instead of minting
51
+ // a fresh one, or a document that used to be one splits into two. To support this, the
52
+ // link fetch and the clustering below include already-migrated neighbor rows, not just
53
+ // pending ones — but only pending (NULL) rows are ever written.
68
54
  const migrateDocumentIdsWithLocalizations = async (db, knex, meta, heartbeat)=>{
69
55
  const singularName = meta.singularName.toLowerCase();
70
56
  const joinColumn = _.snakeCase(`${singularName}_id`);
71
57
  const inverseJoinColumn = _.snakeCase(`inv_${singularName}_id`);
72
- let ids;
73
- let processed = 0;
74
- do {
75
- ids = await getNextIdsToCreateDocumentId(db, knex, {
76
- joinColumn,
77
- inverseJoinColumn,
78
- tableName: meta.tableName,
79
- joinTableName: _.snakeCase(`${meta.tableName}_localizations_links`)
80
- });
81
- if (ids.length > 0) {
82
- await knex(meta.tableName).update({
83
- document_id: cuid2.createId()
84
- }).whereIn('id', ids);
85
- processed += ids.length;
86
- const rowsProcessed = processed;
87
- heartbeat.tick((elapsedSeconds)=>`[document-id] still running (${elapsedSeconds}s) · ${meta.tableName} ${rowsProcessed} rows processed`);
58
+ const joinTableName = _.snakeCase(`${meta.tableName}_localizations_links`);
59
+ const batchSize = getBatchSize(knex);
60
+ const pendingIds = (await knex(meta.tableName).select('id').whereNull('document_id')).map((row)=>row.id);
61
+ if (pendingIds.length === 0) {
62
+ return;
63
+ }
64
+ const pendingIdSet = new Set(pendingIds);
65
+ // Fetch every link touching a pending id, on *either* side of the join — a pending
66
+ // row's sibling may already be migrated, in which case it appears as the other column.
67
+ // Batched because passing all ids in a single `whereIn` can exceed the database
68
+ // driver's bound-parameter limit on large tables.
69
+ const links = [];
70
+ for(let i = 0; i < pendingIds.length; i += batchSize){
71
+ const idChunk = pendingIds.slice(i, i + batchSize);
72
+ const chunkLinks = await knex(joinTableName).select(joinColumn, inverseJoinColumn).whereIn(joinColumn, idChunk).orWhereIn(inverseJoinColumn, idChunk);
73
+ links.push(...chunkLinks);
74
+ }
75
+ // Ids referenced by a link that aren't pending are already-migrated neighbors — look up
76
+ // their document_id so a retry can adopt it instead of minting a new one.
77
+ const neighborIds = new Set();
78
+ for (const link of links){
79
+ const a = link[joinColumn];
80
+ const b = link[inverseJoinColumn];
81
+ if (!pendingIdSet.has(a)) neighborIds.add(a);
82
+ if (!pendingIdSet.has(b)) neighborIds.add(b);
83
+ }
84
+ const existingDocumentIds = new Map();
85
+ const neighborIdList = [
86
+ ...neighborIds
87
+ ];
88
+ for(let i = 0; i < neighborIdList.length; i += batchSize){
89
+ const idChunk = neighborIdList.slice(i, i + batchSize);
90
+ const rows = await knex(meta.tableName).select('id', 'document_id').whereIn('id', idChunk);
91
+ for (const row of rows){
92
+ if (row.document_id) {
93
+ existingDocumentIds.set(row.id, row.document_id);
94
+ }
95
+ }
96
+ }
97
+ const parent = new Map();
98
+ for (const id of pendingIds){
99
+ parent.set(id, id);
100
+ }
101
+ for (const id of neighborIds){
102
+ parent.set(id, id);
103
+ }
104
+ for (const link of links){
105
+ const a = link[joinColumn];
106
+ const b = link[inverseJoinColumn];
107
+ if (parent.has(a) && parent.has(b)) {
108
+ union(parent, a, b);
109
+ }
110
+ }
111
+ const clusters = new Map();
112
+ for (const id of parent.keys()){
113
+ const root = find(parent, id);
114
+ const cluster = clusters.get(root);
115
+ if (cluster) {
116
+ cluster.push(id);
117
+ } else {
118
+ clusters.set(root, [
119
+ id
120
+ ]);
121
+ }
122
+ }
123
+ // Group pending writes by target document_id — one write per cluster instead of one
124
+ // per row shrinks the window for a crash to leave a cluster half-written.
125
+ const writeGroups = new Map();
126
+ for (const members of clusters.values()){
127
+ const existingIdsInCluster = new Set();
128
+ for (const id of members){
129
+ const existing = existingDocumentIds.get(id);
130
+ if (existing) {
131
+ existingIdsInCluster.add(existing);
132
+ }
133
+ }
134
+ // A cluster normally carries at most one existing document_id. More than one means an
135
+ // earlier inconsistent partial write — unify onto a single deterministic id rather
136
+ // than minting a third.
137
+ const documentId = existingIdsInCluster.size > 0 ? [
138
+ ...existingIdsInCluster
139
+ ].sort()[0] : cuid2.createId();
140
+ const pendingMembers = members.filter((id)=>pendingIdSet.has(id));
141
+ if (pendingMembers.length > 0) {
142
+ // Two distinct clusters resolving to the same document_id is only possible via a
143
+ // cuid collision, but merge rather than overwrite so neither group's writes vanish.
144
+ const existingGroup = writeGroups.get(documentId);
145
+ if (existingGroup) {
146
+ existingGroup.push(...pendingMembers);
147
+ } else {
148
+ writeGroups.set(documentId, pendingMembers);
149
+ }
88
150
  }
89
- }while (ids.length > 0)
151
+ }
152
+ const groupEntries = [
153
+ ...writeGroups.entries()
154
+ ];
155
+ let processed = 0;
156
+ for(let i = 0; i < groupEntries.length; i += batchSize){
157
+ const slice = groupEntries.slice(i, i + batchSize);
158
+ await Promise.all(slice.map(([documentId, ids])=>knex(meta.tableName).whereIn('id', ids).update({
159
+ document_id: documentId
160
+ })));
161
+ processed += slice.reduce((sum, [, ids])=>sum + ids.length, 0);
162
+ const rowsProcessed = processed;
163
+ heartbeat.tick((elapsedSeconds)=>`[document-id] still running (${elapsedSeconds}s) · ${meta.tableName} ${rowsProcessed}/${pendingIds.length} rows processed`);
164
+ }
90
165
  };
91
166
  // Migrate document ids for tables that don't have localizations
92
167
  const migrationDocumentIds = async (db, knex, meta, heartbeat)=>{
@@ -1 +1 @@
1
- {"version":3,"file":"5.0.0-02-document-id.js","sources":["../../../src/migrations/internal-migrations/5.0.0-02-document-id.ts"],"sourcesContent":["/**\n * NOTE: This migration avoids using the `identifiers` utility.\n * As the `5.0.0-01-convert-identifiers-long-than-max-length`\n * migration does not convert the `localizations` join tables, as they are not\n * tables that exist anymore in v5 and are not in the db metadata.\n *\n * This migration therefore relies on the fact that those tables still exist, and\n * references them directly.\n *\n * Database join table name: `categories_localizations_links`\n * Actual `identifiers` returned join table name: `categories_localizations_lnk`\n *\n */\nimport { createId } from '@paralleldrive/cuid2';\nimport { snakeCase } from 'lodash/fp';\nimport type { Knex } from 'knex';\n\nimport type { Migration } from '../common';\nimport { createHeartbeatLogger, type HeartbeatLogger } from '../heartbeat';\nimport type { Database } from '../..';\nimport type { Meta } from '../../metadata';\n\nfunction getBatchSize(trx: Knex, defaultSize: number = 1000): number {\n const client = trx.client.config.client;\n const isSQLite =\n typeof client === 'string' && ['sqlite', 'sqlite3', 'better-sqlite3'].includes(client);\n return isSQLite ? Math.min(defaultSize, 250) : defaultSize;\n}\n\ninterface Params {\n joinColumn: string;\n inverseJoinColumn: string;\n tableName: string;\n joinTableName: string;\n}\n\nconst QUERIES = {\n async postgres(knex: Knex, params: Params) {\n const res = await knex.raw(\n `\n SELECT :tableName:.id as id, string_agg(DISTINCT :inverseJoinColumn:::character varying, ',') as other_ids\n FROM :tableName:\n LEFT JOIN :joinTableName: ON :tableName:.id = :joinTableName:.:joinColumn:\n WHERE :tableName:.document_id IS NULL\n GROUP BY :tableName:.id, :joinTableName:.:joinColumn:\n LIMIT 1;\n `,\n params\n );\n\n return res.rows;\n },\n async mysql(knex: Knex, params: Params) {\n const [res] = await knex.raw(\n `\n SELECT :tableName:.id as id, group_concat(DISTINCT :inverseJoinColumn:) as other_ids\n FROM :tableName:\n LEFT JOIN :joinTableName: ON :tableName:.id = :joinTableName:.:joinColumn:\n WHERE :tableName:.document_id IS NULL\n GROUP BY :tableName:.id, :joinTableName:.:joinColumn:\n LIMIT 1;\n `,\n params\n );\n\n return res;\n },\n async sqlite(knex: Knex, params: Params) {\n return knex.raw(\n `\n SELECT :tableName:.id as id, group_concat(DISTINCT :inverseJoinColumn:) as other_ids\n FROM :tableName:\n LEFT JOIN :joinTableName: ON :tableName:.id = :joinTableName:.:joinColumn:\n WHERE :tableName:.document_id IS NULL\n GROUP BY :joinTableName:.:joinColumn:\n LIMIT 1;\n `,\n params\n );\n },\n};\n\nconst getNextIdsToCreateDocumentId = async (\n db: Database,\n knex: Knex,\n {\n joinColumn,\n inverseJoinColumn,\n tableName,\n joinTableName,\n }: {\n joinColumn: string;\n inverseJoinColumn: string;\n tableName: string;\n joinTableName: string;\n }\n): Promise<number[]> => {\n const res = await QUERIES[db.dialect.client as keyof typeof QUERIES](knex, {\n joinColumn,\n inverseJoinColumn,\n tableName,\n joinTableName,\n });\n\n if (res.length > 0) {\n const row = res[0];\n const otherIds = row.other_ids\n ? row.other_ids.split(',').map((v: string) => parseInt(v, 10))\n : [];\n\n return [row.id, ...otherIds];\n }\n\n return [];\n};\n\n// Migrate document ids for tables that have localizations\nconst migrateDocumentIdsWithLocalizations = async (\n db: Database,\n knex: Knex,\n meta: Meta,\n heartbeat: HeartbeatLogger\n) => {\n const singularName = meta.singularName.toLowerCase();\n const joinColumn = snakeCase(`${singularName}_id`);\n const inverseJoinColumn = snakeCase(`inv_${singularName}_id`);\n let ids: number[];\n let processed = 0;\n\n do {\n ids = await getNextIdsToCreateDocumentId(db, knex, {\n joinColumn,\n inverseJoinColumn,\n tableName: meta.tableName,\n joinTableName: snakeCase(`${meta.tableName}_localizations_links`),\n });\n\n if (ids.length > 0) {\n await knex(meta.tableName).update({ document_id: createId() }).whereIn('id', ids);\n processed += ids.length;\n const rowsProcessed = processed;\n heartbeat.tick(\n (elapsedSeconds) =>\n `[document-id] still running (${elapsedSeconds}s) · ${meta.tableName} ${rowsProcessed} rows processed`\n );\n }\n } while (ids.length > 0);\n};\n\n// Migrate document ids for tables that don't have localizations\nconst migrationDocumentIds = async (\n db: Database,\n knex: Knex,\n meta: Meta,\n heartbeat: HeartbeatLogger\n) => {\n const batchSize = getBatchSize(knex);\n const total = +(await knex(meta.tableName).count('* as recordsLeft').whereNull('document_id'))[0]\n .recordsLeft;\n let recordsLeft = total;\n while (recordsLeft > 0) {\n const currentBatchSize = recordsLeft < batchSize ? recordsLeft : batchSize;\n const updateRecords = (\n await knex(meta.tableName).select('id').whereNull('document_id').limit(currentBatchSize)\n ).map((item) => ({ id: item.id, document_id: createId() }));\n await knex(meta.tableName).insert(updateRecords).onConflict('id').merge();\n recordsLeft -= updateRecords.length;\n const processed = total - recordsLeft;\n heartbeat.tick(\n (elapsedSeconds) =>\n `[document-id] still running (${elapsedSeconds}s) · ${meta.tableName} ${processed}/${total}`\n );\n }\n};\n\nconst isDuplicateColumnError = (error: unknown): boolean => {\n if (!error || typeof error !== 'object') return false;\n const e = error as { code?: string; errno?: number; message?: string };\n if (e.code === '42701') return true;\n if (e.errno === 1060) return true;\n if (typeof e.message === 'string' && /duplicate column/i.test(e.message)) return true;\n return false;\n};\n\nconst createDocumentIdColumn = async (knex: Knex, tableName: string) => {\n try {\n await knex.schema.alterTable(tableName, (table) => {\n table.string('document_id');\n });\n } catch (error) {\n if (!isDuplicateColumnError(error)) {\n throw error;\n }\n }\n};\n\nconst hasLocalizationsJoinTable = async (knex: Knex, tableName: string) => {\n const joinTableName = snakeCase(`${tableName}_localizations_links`);\n return knex.schema.hasTable(joinTableName);\n};\n\nexport const createdDocumentId: Migration = {\n name: '5.0.0-02-created-document-id',\n async up(knex, db) {\n const heartbeat = createHeartbeatLogger((message) => {\n db.logger.info(message);\n });\n\n for (const meta of db.metadata.values()) {\n const hasTable = await knex.schema.hasTable(meta.tableName);\n\n if (!hasTable) {\n continue;\n }\n\n if ('documentId' in meta.attributes) {\n const hasDocumentIdColumn = await knex.schema.hasColumn(meta.tableName, 'document_id');\n\n if (!hasDocumentIdColumn) {\n await createDocumentIdColumn(knex, meta.tableName);\n }\n\n if (await hasLocalizationsJoinTable(knex, meta.tableName)) {\n await migrateDocumentIdsWithLocalizations(db, knex, meta, heartbeat);\n } else {\n await migrationDocumentIds(db, knex, meta, heartbeat);\n }\n }\n }\n },\n async down() {\n throw new Error('not implemented');\n },\n};\n"],"names":["getBatchSize","trx","defaultSize","client","config","isSQLite","includes","Math","min","QUERIES","postgres","knex","params","res","raw","rows","mysql","sqlite","getNextIdsToCreateDocumentId","db","joinColumn","inverseJoinColumn","tableName","joinTableName","dialect","length","row","otherIds","other_ids","split","map","v","parseInt","id","migrateDocumentIdsWithLocalizations","meta","heartbeat","singularName","toLowerCase","snakeCase","ids","processed","update","document_id","createId","whereIn","rowsProcessed","tick","elapsedSeconds","migrationDocumentIds","batchSize","total","count","whereNull","recordsLeft","currentBatchSize","updateRecords","select","limit","item","insert","onConflict","merge","isDuplicateColumnError","error","e","code","errno","message","test","createDocumentIdColumn","schema","alterTable","table","string","hasLocalizationsJoinTable","hasTable","createdDocumentId","name","up","createHeartbeatLogger","logger","info","metadata","values","attributes","hasDocumentIdColumn","hasColumn","down","Error"],"mappings":"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"}
1
+ {"version":3,"file":"5.0.0-02-document-id.js","sources":["../../../src/migrations/internal-migrations/5.0.0-02-document-id.ts"],"sourcesContent":["/**\n * NOTE: This migration avoids using the `identifiers` utility.\n * As the `5.0.0-01-convert-identifiers-long-than-max-length`\n * migration does not convert the `localizations` join tables, as they are not\n * tables that exist anymore in v5 and are not in the db metadata.\n *\n * This migration therefore relies on the fact that those tables still exist, and\n * references them directly.\n *\n * Database join table name: `categories_localizations_links`\n * Actual `identifiers` returned join table name: `categories_localizations_lnk`\n *\n */\nimport { createId } from '@paralleldrive/cuid2';\nimport { snakeCase } from 'lodash/fp';\nimport type { Knex } from 'knex';\n\nimport type { Migration } from '../common';\nimport { createHeartbeatLogger, type HeartbeatLogger } from '../heartbeat';\nimport type { Database } from '../..';\nimport type { Meta } from '../../metadata';\n\nfunction getBatchSize(trx: Knex, defaultSize: number = 1000): number {\n const client = trx.client.config.client;\n const isSQLite =\n typeof client === 'string' && ['sqlite', 'sqlite3', 'better-sqlite3'].includes(client);\n return isSQLite ? Math.min(defaultSize, 250) : defaultSize;\n}\n\n// Union-find (disjoint-set) helpers used to cluster localization-linked rows in memory.\n// Path-compressing `find` keeps repeated lookups close to O(1) amortized.\nconst find = (parent: Map<number, number>, start: number): number => {\n let current = start;\n while (parent.get(current) !== current) {\n parent.set(current, parent.get(parent.get(current) as number) as number);\n current = parent.get(current) as number;\n }\n return current;\n};\n\nconst union = (parent: Map<number, number>, a: number, b: number): void => {\n const rootA = find(parent, a);\n const rootB = find(parent, b);\n if (rootA !== rootB) {\n parent.set(rootA, rootB);\n }\n};\n\n// Migrate document ids for tables that have localizations\n//\n// This clusters rows in memory (via union-find) instead of discovering one cluster at a\n// time through a `LIMIT 1` query. The previous implementation re-ran a query that scans\n// the whole table (filtered by `document_id IS NULL`, a column with no index at this point\n// in the migration) once per cluster, which is roughly O(n) discovery queries each doing\n// an O(n) scan — effectively O(n^2) for a table with n rows. For tables with many rows\n// and/or many locales this could take hours or longer without making visible progress.\n// See https://github.com/strapi/strapi/issues/23812, 23517, 25231.\n//\n// This also fixes a correctness gap: the original query only follows *direct* links\n// (one hop), so a chain of locale links (A<->B, B<->C, but no direct A<->C row) could be\n// split across two document ids instead of clustered together. Union-find naturally\n// follows transitive links. See https://github.com/strapi/strapi/issues/20948, 24544.\n//\n// Retry-safety: a previous run can crash between clusters, leaving some rows in a\n// locale group already assigned a document_id while their siblings are still NULL. On\n// retry those NULL rows must adopt the sibling's existing document_id instead of minting\n// a fresh one, or a document that used to be one splits into two. To support this, the\n// link fetch and the clustering below include already-migrated neighbor rows, not just\n// pending ones — but only pending (NULL) rows are ever written.\nconst migrateDocumentIdsWithLocalizations = async (\n db: Database,\n knex: Knex,\n meta: Meta,\n heartbeat: HeartbeatLogger\n) => {\n const singularName = meta.singularName.toLowerCase();\n const joinColumn = snakeCase(`${singularName}_id`);\n const inverseJoinColumn = snakeCase(`inv_${singularName}_id`);\n const joinTableName = snakeCase(`${meta.tableName}_localizations_links`);\n const batchSize = getBatchSize(knex);\n\n const pendingIds: number[] = (\n await knex(meta.tableName).select('id').whereNull('document_id')\n ).map((row: { id: number }) => row.id);\n\n if (pendingIds.length === 0) {\n return;\n }\n\n const pendingIdSet = new Set(pendingIds);\n\n // Fetch every link touching a pending id, on *either* side of the join — a pending\n // row's sibling may already be migrated, in which case it appears as the other column.\n // Batched because passing all ids in a single `whereIn` can exceed the database\n // driver's bound-parameter limit on large tables.\n const links: Array<Record<string, number>> = [];\n for (let i = 0; i < pendingIds.length; i += batchSize) {\n const idChunk = pendingIds.slice(i, i + batchSize);\n const chunkLinks = await knex(joinTableName)\n .select(joinColumn, inverseJoinColumn)\n .whereIn(joinColumn, idChunk)\n .orWhereIn(inverseJoinColumn, idChunk);\n links.push(...chunkLinks);\n }\n\n // Ids referenced by a link that aren't pending are already-migrated neighbors — look up\n // their document_id so a retry can adopt it instead of minting a new one.\n const neighborIds = new Set<number>();\n for (const link of links) {\n const a = link[joinColumn];\n const b = link[inverseJoinColumn];\n if (!pendingIdSet.has(a)) neighborIds.add(a);\n if (!pendingIdSet.has(b)) neighborIds.add(b);\n }\n\n const existingDocumentIds = new Map<number, string>();\n const neighborIdList = [...neighborIds];\n for (let i = 0; i < neighborIdList.length; i += batchSize) {\n const idChunk = neighborIdList.slice(i, i + batchSize);\n const rows: Array<{ id: number; document_id: string | null }> = await knex(meta.tableName)\n .select('id', 'document_id')\n .whereIn('id', idChunk);\n for (const row of rows) {\n if (row.document_id) {\n existingDocumentIds.set(row.id, row.document_id);\n }\n }\n }\n\n const parent = new Map<number, number>();\n for (const id of pendingIds) {\n parent.set(id, id);\n }\n for (const id of neighborIds) {\n parent.set(id, id);\n }\n for (const link of links) {\n const a = link[joinColumn];\n const b = link[inverseJoinColumn];\n if (parent.has(a) && parent.has(b)) {\n union(parent, a, b);\n }\n }\n\n const clusters = new Map<number, number[]>();\n for (const id of parent.keys()) {\n const root = find(parent, id);\n const cluster = clusters.get(root);\n if (cluster) {\n cluster.push(id);\n } else {\n clusters.set(root, [id]);\n }\n }\n\n // Group pending writes by target document_id — one write per cluster instead of one\n // per row shrinks the window for a crash to leave a cluster half-written.\n const writeGroups = new Map<string, number[]>();\n for (const members of clusters.values()) {\n const existingIdsInCluster = new Set<string>();\n for (const id of members) {\n const existing = existingDocumentIds.get(id);\n if (existing) {\n existingIdsInCluster.add(existing);\n }\n }\n\n // A cluster normally carries at most one existing document_id. More than one means an\n // earlier inconsistent partial write — unify onto a single deterministic id rather\n // than minting a third.\n const documentId =\n existingIdsInCluster.size > 0 ? [...existingIdsInCluster].sort()[0] : createId();\n\n const pendingMembers = members.filter((id) => pendingIdSet.has(id));\n if (pendingMembers.length > 0) {\n // Two distinct clusters resolving to the same document_id is only possible via a\n // cuid collision, but merge rather than overwrite so neither group's writes vanish.\n const existingGroup = writeGroups.get(documentId);\n if (existingGroup) {\n existingGroup.push(...pendingMembers);\n } else {\n writeGroups.set(documentId, pendingMembers);\n }\n }\n }\n\n const groupEntries = [...writeGroups.entries()];\n let processed = 0;\n for (let i = 0; i < groupEntries.length; i += batchSize) {\n const slice = groupEntries.slice(i, i + batchSize);\n await Promise.all(\n slice.map(([documentId, ids]) =>\n knex(meta.tableName).whereIn('id', ids).update({ document_id: documentId })\n )\n );\n processed += slice.reduce((sum, [, ids]) => sum + ids.length, 0);\n const rowsProcessed = processed;\n heartbeat.tick(\n (elapsedSeconds) =>\n `[document-id] still running (${elapsedSeconds}s) · ${meta.tableName} ${rowsProcessed}/${pendingIds.length} rows processed`\n );\n }\n};\n\n// Migrate document ids for tables that don't have localizations\nconst migrationDocumentIds = async (\n db: Database,\n knex: Knex,\n meta: Meta,\n heartbeat: HeartbeatLogger\n) => {\n const batchSize = getBatchSize(knex);\n const total = +(await knex(meta.tableName).count('* as recordsLeft').whereNull('document_id'))[0]\n .recordsLeft;\n let recordsLeft = total;\n while (recordsLeft > 0) {\n const currentBatchSize = recordsLeft < batchSize ? recordsLeft : batchSize;\n const updateRecords = (\n await knex(meta.tableName).select('id').whereNull('document_id').limit(currentBatchSize)\n ).map((item) => ({ id: item.id, document_id: createId() }));\n await knex(meta.tableName).insert(updateRecords).onConflict('id').merge();\n recordsLeft -= updateRecords.length;\n const processed = total - recordsLeft;\n heartbeat.tick(\n (elapsedSeconds) =>\n `[document-id] still running (${elapsedSeconds}s) · ${meta.tableName} ${processed}/${total}`\n );\n }\n};\n\nconst isDuplicateColumnError = (error: unknown): boolean => {\n if (!error || typeof error !== 'object') return false;\n const e = error as { code?: string; errno?: number; message?: string };\n if (e.code === '42701') return true;\n if (e.errno === 1060) return true;\n if (typeof e.message === 'string' && /duplicate column/i.test(e.message)) return true;\n return false;\n};\n\nconst createDocumentIdColumn = async (knex: Knex, tableName: string) => {\n try {\n await knex.schema.alterTable(tableName, (table) => {\n table.string('document_id');\n });\n } catch (error) {\n if (!isDuplicateColumnError(error)) {\n throw error;\n }\n }\n};\n\nconst hasLocalizationsJoinTable = async (knex: Knex, tableName: string) => {\n const joinTableName = snakeCase(`${tableName}_localizations_links`);\n return knex.schema.hasTable(joinTableName);\n};\n\nexport const createdDocumentId: Migration = {\n name: '5.0.0-02-created-document-id',\n async up(knex, db) {\n const heartbeat = createHeartbeatLogger((message) => {\n db.logger.info(message);\n });\n\n for (const meta of db.metadata.values()) {\n const hasTable = await knex.schema.hasTable(meta.tableName);\n\n if (!hasTable) {\n continue;\n }\n\n if ('documentId' in meta.attributes) {\n const hasDocumentIdColumn = await knex.schema.hasColumn(meta.tableName, 'document_id');\n\n if (!hasDocumentIdColumn) {\n await createDocumentIdColumn(knex, meta.tableName);\n }\n\n if (await hasLocalizationsJoinTable(knex, meta.tableName)) {\n await migrateDocumentIdsWithLocalizations(db, knex, meta, heartbeat);\n } else {\n await migrationDocumentIds(db, knex, meta, heartbeat);\n }\n }\n }\n },\n async down() {\n throw new Error('not implemented');\n },\n};\n"],"names":["getBatchSize","trx","defaultSize","client","config","isSQLite","includes","Math","min","find","parent","start","current","get","set","union","a","b","rootA","rootB","migrateDocumentIdsWithLocalizations","db","knex","meta","heartbeat","singularName","toLowerCase","joinColumn","snakeCase","inverseJoinColumn","joinTableName","tableName","batchSize","pendingIds","select","whereNull","map","row","id","length","pendingIdSet","Set","links","i","idChunk","slice","chunkLinks","whereIn","orWhereIn","push","neighborIds","link","has","add","existingDocumentIds","Map","neighborIdList","rows","document_id","clusters","keys","root","cluster","writeGroups","members","values","existingIdsInCluster","existing","documentId","size","sort","createId","pendingMembers","filter","existingGroup","groupEntries","entries","processed","Promise","all","ids","update","reduce","sum","rowsProcessed","tick","elapsedSeconds","migrationDocumentIds","total","count","recordsLeft","currentBatchSize","updateRecords","limit","item","insert","onConflict","merge","isDuplicateColumnError","error","e","code","errno","message","test","createDocumentIdColumn","schema","alterTable","table","string","hasLocalizationsJoinTable","hasTable","createdDocumentId","name","up","createHeartbeatLogger","logger","info","metadata","attributes","hasDocumentIdColumn","hasColumn","down","Error"],"mappings":"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"}
@@ -11,80 +11,155 @@ function getBatchSize(trx, defaultSize = 1000) {
11
11
  ].includes(client);
12
12
  return isSQLite ? Math.min(defaultSize, 250) : defaultSize;
13
13
  }
14
- const QUERIES = {
15
- async postgres (knex, params) {
16
- const res = await knex.raw(`
17
- SELECT :tableName:.id as id, string_agg(DISTINCT :inverseJoinColumn:::character varying, ',') as other_ids
18
- FROM :tableName:
19
- LEFT JOIN :joinTableName: ON :tableName:.id = :joinTableName:.:joinColumn:
20
- WHERE :tableName:.document_id IS NULL
21
- GROUP BY :tableName:.id, :joinTableName:.:joinColumn:
22
- LIMIT 1;
23
- `, params);
24
- return res.rows;
25
- },
26
- async mysql (knex, params) {
27
- const [res] = await knex.raw(`
28
- SELECT :tableName:.id as id, group_concat(DISTINCT :inverseJoinColumn:) as other_ids
29
- FROM :tableName:
30
- LEFT JOIN :joinTableName: ON :tableName:.id = :joinTableName:.:joinColumn:
31
- WHERE :tableName:.document_id IS NULL
32
- GROUP BY :tableName:.id, :joinTableName:.:joinColumn:
33
- LIMIT 1;
34
- `, params);
35
- return res;
36
- },
37
- async sqlite (knex, params) {
38
- return knex.raw(`
39
- SELECT :tableName:.id as id, group_concat(DISTINCT :inverseJoinColumn:) as other_ids
40
- FROM :tableName:
41
- LEFT JOIN :joinTableName: ON :tableName:.id = :joinTableName:.:joinColumn:
42
- WHERE :tableName:.document_id IS NULL
43
- GROUP BY :joinTableName:.:joinColumn:
44
- LIMIT 1;
45
- `, params);
14
+ // Union-find (disjoint-set) helpers used to cluster localization-linked rows in memory.
15
+ // Path-compressing `find` keeps repeated lookups close to O(1) amortized.
16
+ const find = (parent, start)=>{
17
+ let current = start;
18
+ while(parent.get(current) !== current){
19
+ parent.set(current, parent.get(parent.get(current)));
20
+ current = parent.get(current);
46
21
  }
22
+ return current;
47
23
  };
48
- const getNextIdsToCreateDocumentId = async (db, knex, { joinColumn, inverseJoinColumn, tableName, joinTableName })=>{
49
- const res = await QUERIES[db.dialect.client](knex, {
50
- joinColumn,
51
- inverseJoinColumn,
52
- tableName,
53
- joinTableName
54
- });
55
- if (res.length > 0) {
56
- const row = res[0];
57
- const otherIds = row.other_ids ? row.other_ids.split(',').map((v)=>parseInt(v, 10)) : [];
58
- return [
59
- row.id,
60
- ...otherIds
61
- ];
24
+ const union = (parent, a, b)=>{
25
+ const rootA = find(parent, a);
26
+ const rootB = find(parent, b);
27
+ if (rootA !== rootB) {
28
+ parent.set(rootA, rootB);
62
29
  }
63
- return [];
64
30
  };
65
31
  // Migrate document ids for tables that have localizations
32
+ //
33
+ // This clusters rows in memory (via union-find) instead of discovering one cluster at a
34
+ // time through a `LIMIT 1` query. The previous implementation re-ran a query that scans
35
+ // the whole table (filtered by `document_id IS NULL`, a column with no index at this point
36
+ // in the migration) once per cluster, which is roughly O(n) discovery queries each doing
37
+ // an O(n) scan — effectively O(n^2) for a table with n rows. For tables with many rows
38
+ // and/or many locales this could take hours or longer without making visible progress.
39
+ // See https://github.com/strapi/strapi/issues/23812, 23517, 25231.
40
+ //
41
+ // This also fixes a correctness gap: the original query only follows *direct* links
42
+ // (one hop), so a chain of locale links (A<->B, B<->C, but no direct A<->C row) could be
43
+ // split across two document ids instead of clustered together. Union-find naturally
44
+ // follows transitive links. See https://github.com/strapi/strapi/issues/20948, 24544.
45
+ //
46
+ // Retry-safety: a previous run can crash between clusters, leaving some rows in a
47
+ // locale group already assigned a document_id while their siblings are still NULL. On
48
+ // retry those NULL rows must adopt the sibling's existing document_id instead of minting
49
+ // a fresh one, or a document that used to be one splits into two. To support this, the
50
+ // link fetch and the clustering below include already-migrated neighbor rows, not just
51
+ // pending ones — but only pending (NULL) rows are ever written.
66
52
  const migrateDocumentIdsWithLocalizations = async (db, knex, meta, heartbeat)=>{
67
53
  const singularName = meta.singularName.toLowerCase();
68
54
  const joinColumn = snakeCase(`${singularName}_id`);
69
55
  const inverseJoinColumn = snakeCase(`inv_${singularName}_id`);
70
- let ids;
71
- let processed = 0;
72
- do {
73
- ids = await getNextIdsToCreateDocumentId(db, knex, {
74
- joinColumn,
75
- inverseJoinColumn,
76
- tableName: meta.tableName,
77
- joinTableName: snakeCase(`${meta.tableName}_localizations_links`)
78
- });
79
- if (ids.length > 0) {
80
- await knex(meta.tableName).update({
81
- document_id: createId()
82
- }).whereIn('id', ids);
83
- processed += ids.length;
84
- const rowsProcessed = processed;
85
- heartbeat.tick((elapsedSeconds)=>`[document-id] still running (${elapsedSeconds}s) · ${meta.tableName} ${rowsProcessed} rows processed`);
56
+ const joinTableName = snakeCase(`${meta.tableName}_localizations_links`);
57
+ const batchSize = getBatchSize(knex);
58
+ const pendingIds = (await knex(meta.tableName).select('id').whereNull('document_id')).map((row)=>row.id);
59
+ if (pendingIds.length === 0) {
60
+ return;
61
+ }
62
+ const pendingIdSet = new Set(pendingIds);
63
+ // Fetch every link touching a pending id, on *either* side of the join — a pending
64
+ // row's sibling may already be migrated, in which case it appears as the other column.
65
+ // Batched because passing all ids in a single `whereIn` can exceed the database
66
+ // driver's bound-parameter limit on large tables.
67
+ const links = [];
68
+ for(let i = 0; i < pendingIds.length; i += batchSize){
69
+ const idChunk = pendingIds.slice(i, i + batchSize);
70
+ const chunkLinks = await knex(joinTableName).select(joinColumn, inverseJoinColumn).whereIn(joinColumn, idChunk).orWhereIn(inverseJoinColumn, idChunk);
71
+ links.push(...chunkLinks);
72
+ }
73
+ // Ids referenced by a link that aren't pending are already-migrated neighbors — look up
74
+ // their document_id so a retry can adopt it instead of minting a new one.
75
+ const neighborIds = new Set();
76
+ for (const link of links){
77
+ const a = link[joinColumn];
78
+ const b = link[inverseJoinColumn];
79
+ if (!pendingIdSet.has(a)) neighborIds.add(a);
80
+ if (!pendingIdSet.has(b)) neighborIds.add(b);
81
+ }
82
+ const existingDocumentIds = new Map();
83
+ const neighborIdList = [
84
+ ...neighborIds
85
+ ];
86
+ for(let i = 0; i < neighborIdList.length; i += batchSize){
87
+ const idChunk = neighborIdList.slice(i, i + batchSize);
88
+ const rows = await knex(meta.tableName).select('id', 'document_id').whereIn('id', idChunk);
89
+ for (const row of rows){
90
+ if (row.document_id) {
91
+ existingDocumentIds.set(row.id, row.document_id);
92
+ }
93
+ }
94
+ }
95
+ const parent = new Map();
96
+ for (const id of pendingIds){
97
+ parent.set(id, id);
98
+ }
99
+ for (const id of neighborIds){
100
+ parent.set(id, id);
101
+ }
102
+ for (const link of links){
103
+ const a = link[joinColumn];
104
+ const b = link[inverseJoinColumn];
105
+ if (parent.has(a) && parent.has(b)) {
106
+ union(parent, a, b);
107
+ }
108
+ }
109
+ const clusters = new Map();
110
+ for (const id of parent.keys()){
111
+ const root = find(parent, id);
112
+ const cluster = clusters.get(root);
113
+ if (cluster) {
114
+ cluster.push(id);
115
+ } else {
116
+ clusters.set(root, [
117
+ id
118
+ ]);
119
+ }
120
+ }
121
+ // Group pending writes by target document_id — one write per cluster instead of one
122
+ // per row shrinks the window for a crash to leave a cluster half-written.
123
+ const writeGroups = new Map();
124
+ for (const members of clusters.values()){
125
+ const existingIdsInCluster = new Set();
126
+ for (const id of members){
127
+ const existing = existingDocumentIds.get(id);
128
+ if (existing) {
129
+ existingIdsInCluster.add(existing);
130
+ }
131
+ }
132
+ // A cluster normally carries at most one existing document_id. More than one means an
133
+ // earlier inconsistent partial write — unify onto a single deterministic id rather
134
+ // than minting a third.
135
+ const documentId = existingIdsInCluster.size > 0 ? [
136
+ ...existingIdsInCluster
137
+ ].sort()[0] : createId();
138
+ const pendingMembers = members.filter((id)=>pendingIdSet.has(id));
139
+ if (pendingMembers.length > 0) {
140
+ // Two distinct clusters resolving to the same document_id is only possible via a
141
+ // cuid collision, but merge rather than overwrite so neither group's writes vanish.
142
+ const existingGroup = writeGroups.get(documentId);
143
+ if (existingGroup) {
144
+ existingGroup.push(...pendingMembers);
145
+ } else {
146
+ writeGroups.set(documentId, pendingMembers);
147
+ }
86
148
  }
87
- }while (ids.length > 0)
149
+ }
150
+ const groupEntries = [
151
+ ...writeGroups.entries()
152
+ ];
153
+ let processed = 0;
154
+ for(let i = 0; i < groupEntries.length; i += batchSize){
155
+ const slice = groupEntries.slice(i, i + batchSize);
156
+ await Promise.all(slice.map(([documentId, ids])=>knex(meta.tableName).whereIn('id', ids).update({
157
+ document_id: documentId
158
+ })));
159
+ processed += slice.reduce((sum, [, ids])=>sum + ids.length, 0);
160
+ const rowsProcessed = processed;
161
+ heartbeat.tick((elapsedSeconds)=>`[document-id] still running (${elapsedSeconds}s) · ${meta.tableName} ${rowsProcessed}/${pendingIds.length} rows processed`);
162
+ }
88
163
  };
89
164
  // Migrate document ids for tables that don't have localizations
90
165
  const migrationDocumentIds = async (db, knex, meta, heartbeat)=>{
@@ -1 +1 @@
1
- {"version":3,"file":"5.0.0-02-document-id.mjs","sources":["../../../src/migrations/internal-migrations/5.0.0-02-document-id.ts"],"sourcesContent":["/**\n * NOTE: This migration avoids using the `identifiers` utility.\n * As the `5.0.0-01-convert-identifiers-long-than-max-length`\n * migration does not convert the `localizations` join tables, as they are not\n * tables that exist anymore in v5 and are not in the db metadata.\n *\n * This migration therefore relies on the fact that those tables still exist, and\n * references them directly.\n *\n * Database join table name: `categories_localizations_links`\n * Actual `identifiers` returned join table name: `categories_localizations_lnk`\n *\n */\nimport { createId } from '@paralleldrive/cuid2';\nimport { snakeCase } from 'lodash/fp';\nimport type { Knex } from 'knex';\n\nimport type { Migration } from '../common';\nimport { createHeartbeatLogger, type HeartbeatLogger } from '../heartbeat';\nimport type { Database } from '../..';\nimport type { Meta } from '../../metadata';\n\nfunction getBatchSize(trx: Knex, defaultSize: number = 1000): number {\n const client = trx.client.config.client;\n const isSQLite =\n typeof client === 'string' && ['sqlite', 'sqlite3', 'better-sqlite3'].includes(client);\n return isSQLite ? Math.min(defaultSize, 250) : defaultSize;\n}\n\ninterface Params {\n joinColumn: string;\n inverseJoinColumn: string;\n tableName: string;\n joinTableName: string;\n}\n\nconst QUERIES = {\n async postgres(knex: Knex, params: Params) {\n const res = await knex.raw(\n `\n SELECT :tableName:.id as id, string_agg(DISTINCT :inverseJoinColumn:::character varying, ',') as other_ids\n FROM :tableName:\n LEFT JOIN :joinTableName: ON :tableName:.id = :joinTableName:.:joinColumn:\n WHERE :tableName:.document_id IS NULL\n GROUP BY :tableName:.id, :joinTableName:.:joinColumn:\n LIMIT 1;\n `,\n params\n );\n\n return res.rows;\n },\n async mysql(knex: Knex, params: Params) {\n const [res] = await knex.raw(\n `\n SELECT :tableName:.id as id, group_concat(DISTINCT :inverseJoinColumn:) as other_ids\n FROM :tableName:\n LEFT JOIN :joinTableName: ON :tableName:.id = :joinTableName:.:joinColumn:\n WHERE :tableName:.document_id IS NULL\n GROUP BY :tableName:.id, :joinTableName:.:joinColumn:\n LIMIT 1;\n `,\n params\n );\n\n return res;\n },\n async sqlite(knex: Knex, params: Params) {\n return knex.raw(\n `\n SELECT :tableName:.id as id, group_concat(DISTINCT :inverseJoinColumn:) as other_ids\n FROM :tableName:\n LEFT JOIN :joinTableName: ON :tableName:.id = :joinTableName:.:joinColumn:\n WHERE :tableName:.document_id IS NULL\n GROUP BY :joinTableName:.:joinColumn:\n LIMIT 1;\n `,\n params\n );\n },\n};\n\nconst getNextIdsToCreateDocumentId = async (\n db: Database,\n knex: Knex,\n {\n joinColumn,\n inverseJoinColumn,\n tableName,\n joinTableName,\n }: {\n joinColumn: string;\n inverseJoinColumn: string;\n tableName: string;\n joinTableName: string;\n }\n): Promise<number[]> => {\n const res = await QUERIES[db.dialect.client as keyof typeof QUERIES](knex, {\n joinColumn,\n inverseJoinColumn,\n tableName,\n joinTableName,\n });\n\n if (res.length > 0) {\n const row = res[0];\n const otherIds = row.other_ids\n ? row.other_ids.split(',').map((v: string) => parseInt(v, 10))\n : [];\n\n return [row.id, ...otherIds];\n }\n\n return [];\n};\n\n// Migrate document ids for tables that have localizations\nconst migrateDocumentIdsWithLocalizations = async (\n db: Database,\n knex: Knex,\n meta: Meta,\n heartbeat: HeartbeatLogger\n) => {\n const singularName = meta.singularName.toLowerCase();\n const joinColumn = snakeCase(`${singularName}_id`);\n const inverseJoinColumn = snakeCase(`inv_${singularName}_id`);\n let ids: number[];\n let processed = 0;\n\n do {\n ids = await getNextIdsToCreateDocumentId(db, knex, {\n joinColumn,\n inverseJoinColumn,\n tableName: meta.tableName,\n joinTableName: snakeCase(`${meta.tableName}_localizations_links`),\n });\n\n if (ids.length > 0) {\n await knex(meta.tableName).update({ document_id: createId() }).whereIn('id', ids);\n processed += ids.length;\n const rowsProcessed = processed;\n heartbeat.tick(\n (elapsedSeconds) =>\n `[document-id] still running (${elapsedSeconds}s) · ${meta.tableName} ${rowsProcessed} rows processed`\n );\n }\n } while (ids.length > 0);\n};\n\n// Migrate document ids for tables that don't have localizations\nconst migrationDocumentIds = async (\n db: Database,\n knex: Knex,\n meta: Meta,\n heartbeat: HeartbeatLogger\n) => {\n const batchSize = getBatchSize(knex);\n const total = +(await knex(meta.tableName).count('* as recordsLeft').whereNull('document_id'))[0]\n .recordsLeft;\n let recordsLeft = total;\n while (recordsLeft > 0) {\n const currentBatchSize = recordsLeft < batchSize ? recordsLeft : batchSize;\n const updateRecords = (\n await knex(meta.tableName).select('id').whereNull('document_id').limit(currentBatchSize)\n ).map((item) => ({ id: item.id, document_id: createId() }));\n await knex(meta.tableName).insert(updateRecords).onConflict('id').merge();\n recordsLeft -= updateRecords.length;\n const processed = total - recordsLeft;\n heartbeat.tick(\n (elapsedSeconds) =>\n `[document-id] still running (${elapsedSeconds}s) · ${meta.tableName} ${processed}/${total}`\n );\n }\n};\n\nconst isDuplicateColumnError = (error: unknown): boolean => {\n if (!error || typeof error !== 'object') return false;\n const e = error as { code?: string; errno?: number; message?: string };\n if (e.code === '42701') return true;\n if (e.errno === 1060) return true;\n if (typeof e.message === 'string' && /duplicate column/i.test(e.message)) return true;\n return false;\n};\n\nconst createDocumentIdColumn = async (knex: Knex, tableName: string) => {\n try {\n await knex.schema.alterTable(tableName, (table) => {\n table.string('document_id');\n });\n } catch (error) {\n if (!isDuplicateColumnError(error)) {\n throw error;\n }\n }\n};\n\nconst hasLocalizationsJoinTable = async (knex: Knex, tableName: string) => {\n const joinTableName = snakeCase(`${tableName}_localizations_links`);\n return knex.schema.hasTable(joinTableName);\n};\n\nexport const createdDocumentId: Migration = {\n name: '5.0.0-02-created-document-id',\n async up(knex, db) {\n const heartbeat = createHeartbeatLogger((message) => {\n db.logger.info(message);\n });\n\n for (const meta of db.metadata.values()) {\n const hasTable = await knex.schema.hasTable(meta.tableName);\n\n if (!hasTable) {\n continue;\n }\n\n if ('documentId' in meta.attributes) {\n const hasDocumentIdColumn = await knex.schema.hasColumn(meta.tableName, 'document_id');\n\n if (!hasDocumentIdColumn) {\n await createDocumentIdColumn(knex, meta.tableName);\n }\n\n if (await hasLocalizationsJoinTable(knex, meta.tableName)) {\n await migrateDocumentIdsWithLocalizations(db, knex, meta, heartbeat);\n } else {\n await migrationDocumentIds(db, knex, meta, heartbeat);\n }\n }\n }\n },\n async down() {\n throw new Error('not implemented');\n },\n};\n"],"names":["getBatchSize","trx","defaultSize","client","config","isSQLite","includes","Math","min","QUERIES","postgres","knex","params","res","raw","rows","mysql","sqlite","getNextIdsToCreateDocumentId","db","joinColumn","inverseJoinColumn","tableName","joinTableName","dialect","length","row","otherIds","other_ids","split","map","v","parseInt","id","migrateDocumentIdsWithLocalizations","meta","heartbeat","singularName","toLowerCase","snakeCase","ids","processed","update","document_id","createId","whereIn","rowsProcessed","tick","elapsedSeconds","migrationDocumentIds","batchSize","total","count","whereNull","recordsLeft","currentBatchSize","updateRecords","select","limit","item","insert","onConflict","merge","isDuplicateColumnError","error","e","code","errno","message","test","createDocumentIdColumn","schema","alterTable","table","string","hasLocalizationsJoinTable","hasTable","createdDocumentId","name","up","createHeartbeatLogger","logger","info","metadata","values","attributes","hasDocumentIdColumn","hasColumn","down","Error"],"mappings":"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"}
1
+ {"version":3,"file":"5.0.0-02-document-id.mjs","sources":["../../../src/migrations/internal-migrations/5.0.0-02-document-id.ts"],"sourcesContent":["/**\n * NOTE: This migration avoids using the `identifiers` utility.\n * As the `5.0.0-01-convert-identifiers-long-than-max-length`\n * migration does not convert the `localizations` join tables, as they are not\n * tables that exist anymore in v5 and are not in the db metadata.\n *\n * This migration therefore relies on the fact that those tables still exist, and\n * references them directly.\n *\n * Database join table name: `categories_localizations_links`\n * Actual `identifiers` returned join table name: `categories_localizations_lnk`\n *\n */\nimport { createId } from '@paralleldrive/cuid2';\nimport { snakeCase } from 'lodash/fp';\nimport type { Knex } from 'knex';\n\nimport type { Migration } from '../common';\nimport { createHeartbeatLogger, type HeartbeatLogger } from '../heartbeat';\nimport type { Database } from '../..';\nimport type { Meta } from '../../metadata';\n\nfunction getBatchSize(trx: Knex, defaultSize: number = 1000): number {\n const client = trx.client.config.client;\n const isSQLite =\n typeof client === 'string' && ['sqlite', 'sqlite3', 'better-sqlite3'].includes(client);\n return isSQLite ? Math.min(defaultSize, 250) : defaultSize;\n}\n\n// Union-find (disjoint-set) helpers used to cluster localization-linked rows in memory.\n// Path-compressing `find` keeps repeated lookups close to O(1) amortized.\nconst find = (parent: Map<number, number>, start: number): number => {\n let current = start;\n while (parent.get(current) !== current) {\n parent.set(current, parent.get(parent.get(current) as number) as number);\n current = parent.get(current) as number;\n }\n return current;\n};\n\nconst union = (parent: Map<number, number>, a: number, b: number): void => {\n const rootA = find(parent, a);\n const rootB = find(parent, b);\n if (rootA !== rootB) {\n parent.set(rootA, rootB);\n }\n};\n\n// Migrate document ids for tables that have localizations\n//\n// This clusters rows in memory (via union-find) instead of discovering one cluster at a\n// time through a `LIMIT 1` query. The previous implementation re-ran a query that scans\n// the whole table (filtered by `document_id IS NULL`, a column with no index at this point\n// in the migration) once per cluster, which is roughly O(n) discovery queries each doing\n// an O(n) scan — effectively O(n^2) for a table with n rows. For tables with many rows\n// and/or many locales this could take hours or longer without making visible progress.\n// See https://github.com/strapi/strapi/issues/23812, 23517, 25231.\n//\n// This also fixes a correctness gap: the original query only follows *direct* links\n// (one hop), so a chain of locale links (A<->B, B<->C, but no direct A<->C row) could be\n// split across two document ids instead of clustered together. Union-find naturally\n// follows transitive links. See https://github.com/strapi/strapi/issues/20948, 24544.\n//\n// Retry-safety: a previous run can crash between clusters, leaving some rows in a\n// locale group already assigned a document_id while their siblings are still NULL. On\n// retry those NULL rows must adopt the sibling's existing document_id instead of minting\n// a fresh one, or a document that used to be one splits into two. To support this, the\n// link fetch and the clustering below include already-migrated neighbor rows, not just\n// pending ones — but only pending (NULL) rows are ever written.\nconst migrateDocumentIdsWithLocalizations = async (\n db: Database,\n knex: Knex,\n meta: Meta,\n heartbeat: HeartbeatLogger\n) => {\n const singularName = meta.singularName.toLowerCase();\n const joinColumn = snakeCase(`${singularName}_id`);\n const inverseJoinColumn = snakeCase(`inv_${singularName}_id`);\n const joinTableName = snakeCase(`${meta.tableName}_localizations_links`);\n const batchSize = getBatchSize(knex);\n\n const pendingIds: number[] = (\n await knex(meta.tableName).select('id').whereNull('document_id')\n ).map((row: { id: number }) => row.id);\n\n if (pendingIds.length === 0) {\n return;\n }\n\n const pendingIdSet = new Set(pendingIds);\n\n // Fetch every link touching a pending id, on *either* side of the join — a pending\n // row's sibling may already be migrated, in which case it appears as the other column.\n // Batched because passing all ids in a single `whereIn` can exceed the database\n // driver's bound-parameter limit on large tables.\n const links: Array<Record<string, number>> = [];\n for (let i = 0; i < pendingIds.length; i += batchSize) {\n const idChunk = pendingIds.slice(i, i + batchSize);\n const chunkLinks = await knex(joinTableName)\n .select(joinColumn, inverseJoinColumn)\n .whereIn(joinColumn, idChunk)\n .orWhereIn(inverseJoinColumn, idChunk);\n links.push(...chunkLinks);\n }\n\n // Ids referenced by a link that aren't pending are already-migrated neighbors — look up\n // their document_id so a retry can adopt it instead of minting a new one.\n const neighborIds = new Set<number>();\n for (const link of links) {\n const a = link[joinColumn];\n const b = link[inverseJoinColumn];\n if (!pendingIdSet.has(a)) neighborIds.add(a);\n if (!pendingIdSet.has(b)) neighborIds.add(b);\n }\n\n const existingDocumentIds = new Map<number, string>();\n const neighborIdList = [...neighborIds];\n for (let i = 0; i < neighborIdList.length; i += batchSize) {\n const idChunk = neighborIdList.slice(i, i + batchSize);\n const rows: Array<{ id: number; document_id: string | null }> = await knex(meta.tableName)\n .select('id', 'document_id')\n .whereIn('id', idChunk);\n for (const row of rows) {\n if (row.document_id) {\n existingDocumentIds.set(row.id, row.document_id);\n }\n }\n }\n\n const parent = new Map<number, number>();\n for (const id of pendingIds) {\n parent.set(id, id);\n }\n for (const id of neighborIds) {\n parent.set(id, id);\n }\n for (const link of links) {\n const a = link[joinColumn];\n const b = link[inverseJoinColumn];\n if (parent.has(a) && parent.has(b)) {\n union(parent, a, b);\n }\n }\n\n const clusters = new Map<number, number[]>();\n for (const id of parent.keys()) {\n const root = find(parent, id);\n const cluster = clusters.get(root);\n if (cluster) {\n cluster.push(id);\n } else {\n clusters.set(root, [id]);\n }\n }\n\n // Group pending writes by target document_id — one write per cluster instead of one\n // per row shrinks the window for a crash to leave a cluster half-written.\n const writeGroups = new Map<string, number[]>();\n for (const members of clusters.values()) {\n const existingIdsInCluster = new Set<string>();\n for (const id of members) {\n const existing = existingDocumentIds.get(id);\n if (existing) {\n existingIdsInCluster.add(existing);\n }\n }\n\n // A cluster normally carries at most one existing document_id. More than one means an\n // earlier inconsistent partial write — unify onto a single deterministic id rather\n // than minting a third.\n const documentId =\n existingIdsInCluster.size > 0 ? [...existingIdsInCluster].sort()[0] : createId();\n\n const pendingMembers = members.filter((id) => pendingIdSet.has(id));\n if (pendingMembers.length > 0) {\n // Two distinct clusters resolving to the same document_id is only possible via a\n // cuid collision, but merge rather than overwrite so neither group's writes vanish.\n const existingGroup = writeGroups.get(documentId);\n if (existingGroup) {\n existingGroup.push(...pendingMembers);\n } else {\n writeGroups.set(documentId, pendingMembers);\n }\n }\n }\n\n const groupEntries = [...writeGroups.entries()];\n let processed = 0;\n for (let i = 0; i < groupEntries.length; i += batchSize) {\n const slice = groupEntries.slice(i, i + batchSize);\n await Promise.all(\n slice.map(([documentId, ids]) =>\n knex(meta.tableName).whereIn('id', ids).update({ document_id: documentId })\n )\n );\n processed += slice.reduce((sum, [, ids]) => sum + ids.length, 0);\n const rowsProcessed = processed;\n heartbeat.tick(\n (elapsedSeconds) =>\n `[document-id] still running (${elapsedSeconds}s) · ${meta.tableName} ${rowsProcessed}/${pendingIds.length} rows processed`\n );\n }\n};\n\n// Migrate document ids for tables that don't have localizations\nconst migrationDocumentIds = async (\n db: Database,\n knex: Knex,\n meta: Meta,\n heartbeat: HeartbeatLogger\n) => {\n const batchSize = getBatchSize(knex);\n const total = +(await knex(meta.tableName).count('* as recordsLeft').whereNull('document_id'))[0]\n .recordsLeft;\n let recordsLeft = total;\n while (recordsLeft > 0) {\n const currentBatchSize = recordsLeft < batchSize ? recordsLeft : batchSize;\n const updateRecords = (\n await knex(meta.tableName).select('id').whereNull('document_id').limit(currentBatchSize)\n ).map((item) => ({ id: item.id, document_id: createId() }));\n await knex(meta.tableName).insert(updateRecords).onConflict('id').merge();\n recordsLeft -= updateRecords.length;\n const processed = total - recordsLeft;\n heartbeat.tick(\n (elapsedSeconds) =>\n `[document-id] still running (${elapsedSeconds}s) · ${meta.tableName} ${processed}/${total}`\n );\n }\n};\n\nconst isDuplicateColumnError = (error: unknown): boolean => {\n if (!error || typeof error !== 'object') return false;\n const e = error as { code?: string; errno?: number; message?: string };\n if (e.code === '42701') return true;\n if (e.errno === 1060) return true;\n if (typeof e.message === 'string' && /duplicate column/i.test(e.message)) return true;\n return false;\n};\n\nconst createDocumentIdColumn = async (knex: Knex, tableName: string) => {\n try {\n await knex.schema.alterTable(tableName, (table) => {\n table.string('document_id');\n });\n } catch (error) {\n if (!isDuplicateColumnError(error)) {\n throw error;\n }\n }\n};\n\nconst hasLocalizationsJoinTable = async (knex: Knex, tableName: string) => {\n const joinTableName = snakeCase(`${tableName}_localizations_links`);\n return knex.schema.hasTable(joinTableName);\n};\n\nexport const createdDocumentId: Migration = {\n name: '5.0.0-02-created-document-id',\n async up(knex, db) {\n const heartbeat = createHeartbeatLogger((message) => {\n db.logger.info(message);\n });\n\n for (const meta of db.metadata.values()) {\n const hasTable = await knex.schema.hasTable(meta.tableName);\n\n if (!hasTable) {\n continue;\n }\n\n if ('documentId' in meta.attributes) {\n const hasDocumentIdColumn = await knex.schema.hasColumn(meta.tableName, 'document_id');\n\n if (!hasDocumentIdColumn) {\n await createDocumentIdColumn(knex, meta.tableName);\n }\n\n if (await hasLocalizationsJoinTable(knex, meta.tableName)) {\n await migrateDocumentIdsWithLocalizations(db, knex, meta, heartbeat);\n } else {\n await migrationDocumentIds(db, knex, meta, heartbeat);\n }\n }\n }\n },\n async down() {\n throw new Error('not implemented');\n },\n};\n"],"names":["getBatchSize","trx","defaultSize","client","config","isSQLite","includes","Math","min","find","parent","start","current","get","set","union","a","b","rootA","rootB","migrateDocumentIdsWithLocalizations","db","knex","meta","heartbeat","singularName","toLowerCase","joinColumn","snakeCase","inverseJoinColumn","joinTableName","tableName","batchSize","pendingIds","select","whereNull","map","row","id","length","pendingIdSet","Set","links","i","idChunk","slice","chunkLinks","whereIn","orWhereIn","push","neighborIds","link","has","add","existingDocumentIds","Map","neighborIdList","rows","document_id","clusters","keys","root","cluster","writeGroups","members","values","existingIdsInCluster","existing","documentId","size","sort","createId","pendingMembers","filter","existingGroup","groupEntries","entries","processed","Promise","all","ids","update","reduce","sum","rowsProcessed","tick","elapsedSeconds","migrationDocumentIds","total","count","recordsLeft","currentBatchSize","updateRecords","limit","item","insert","onConflict","merge","isDuplicateColumnError","error","e","code","errno","message","test","createDocumentIdColumn","schema","alterTable","table","string","hasLocalizationsJoinTable","hasTable","createdDocumentId","name","up","createHeartbeatLogger","logger","info","metadata","attributes","hasDocumentIdColumn","hasColumn","down","Error"],"mappings":"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"}