@@ -51,38 +51,133 @@ jobs:
5151 gzip -k -f /tmp/production.ndjson
5252 wc -l /tmp/production.ndjson
5353
54- echo "3. Importing documents into 'dev' dataset via Sanity Mutate API ..."
54+ echo "3. Importing documents and referenced image binaries into 'dev' dataset..."
5555 cat << 'EOF' > /tmp/sync.cjs
5656 const fs = require("fs");
5757 const projectId = process.env.SANITY_STUDIO_PROJECT_ID || "hfh83o0w";
5858 const tokens = [process.env.SANITY_API_WRITE_TOKEN, process.env.SANITY_AUTH_TOKEN].filter(Boolean);
5959 const lines = fs.readFileSync("/tmp/production.ndjson", "utf8").trim().split("\n").filter(Boolean);
6060 const allDocs = lines.map((l) => JSON.parse(l));
61+
62+ const assetDocs = allDocs.filter((d) => d._type === "sanity.imageAsset" || d._type === "sanity.fileAsset");
63+ const imageAssetMap = new Map(
64+ allDocs.filter((d) => d._type === "sanity.imageAsset").map((d) => [d._id, d])
65+ );
6166 const contentDocs = allDocs.filter((d) => d._type !== "sanity.imageAsset" && d._type !== "sanity.fileAsset");
62- const contentIds = new Set(contentDocs.map((d) => d._id));
67+
68+ // Collect all known IDs (including assets and syndication.* targets) so valid strong references are never weakened
69+ const allTargetIds = new Set(allDocs.map((d) => d._id));
70+ const syndicationStubs = new Map();
71+
72+ for (const doc of contentDocs) {
73+ if (Array.isArray(doc.syndications)) {
74+ for (const item of doc.syndications) {
75+ if (item && typeof item._ref === "string" && item._ref.startsWith("syndication.")) {
76+ allTargetIds.add(item._ref);
77+ if (!contentDocs.some((d) => d._id === item._ref)) {
78+ const platform = item._ref.endsWith(".devto") ? "devto" : "hashnode";
79+ const parentId = doc._id.replace(/^drafts\./, "");
80+ syndicationStubs.set(item._ref, {
81+ _id: item._ref,
82+ _type: "syndication",
83+ platform,
84+ status: "simulated_complete",
85+ externalUrl: `https://${platform === "devto" ? "dev.to" : "hashnode.com"}/codingcatdev`,
86+ syndicatedAt: doc._updatedAt || new Date().toISOString(),
87+ target: { _type: "reference", _ref: parentId, _weak: true },
88+ });
89+ }
90+ }
91+ }
92+ }
93+ }
94+
95+ // Track any production documents that have duplicate syndications keys so we can clean them up in production too
96+ const prodDeduplicateMutations = [];
97+ for (const doc of contentDocs) {
98+ if (Array.isArray(doc.syndications) && doc.syndications.length > 0) {
99+ const seenRefs = new Set();
100+ const seenKeys = new Set();
101+ const deduped = [];
102+ let changed = false;
103+
104+ for (const item of doc.syndications) {
105+ if (!item || typeof item._ref !== "string") continue;
106+ if (seenRefs.has(item._ref)) {
107+ changed = true;
108+ continue;
109+ }
110+ seenRefs.add(item._ref);
111+ const detKey = `syn_${item._ref.replace(/[^a-zA-Z0-9]/g, "_")}`;
112+ const key = item._key && !seenKeys.has(item._key) ? item._key : detKey;
113+ if (key !== item._key || item._weak) {
114+ changed = true;
115+ }
116+ seenKeys.add(key);
117+ deduped.push({
118+ _key: key,
119+ _type: "reference",
120+ _ref: item._ref,
121+ });
122+ }
123+
124+ if (changed) {
125+ doc.syndications = deduped;
126+ prodDeduplicateMutations.push({
127+ patch: {
128+ id: doc._id,
129+ set: { syndications: deduped },
130+ },
131+ });
132+ }
133+ }
134+ }
63135
64136 function sanitizeDoc(obj) {
65137 if (!obj || typeof obj !== "object") return obj;
66- if (Array.isArray(obj)) return obj.map(sanitizeDoc);
138+ if (Array.isArray(obj)) {
139+ const mapped = obj.map(sanitizeDoc);
140+ const usedKeys = new Set();
141+ const deduped = [];
142+ for (const item of mapped) {
143+ if (item && typeof item === "object" && typeof item._key === "string") {
144+ if (usedKeys.has(item._key)) {
145+ if (typeof item._ref === "string") {
146+ // Duplicate reference key in array: skip duplicate entry
147+ continue;
148+ }
149+ item._key = `${item._key}_${usedKeys.size}`;
150+ }
151+ usedKeys.add(item._key);
152+ }
153+ deduped.push(item);
154+ }
155+ return deduped;
156+ }
67157 const copy = {};
68158 for (const [k, v] of Object.entries(obj)) {
69159 if (k === "_rev") continue;
70160 copy[k] = sanitizeDoc(v);
71161 }
72- if (typeof copy._ref === "string" && !contentIds.has(copy._ref)) {
73- copy._weak = true;
162+ if (typeof copy._ref === "string") {
163+ if (allTargetIds.has(copy._ref)) {
164+ delete copy._weak;
165+ delete copy._strengthenOnPublish;
166+ } else {
167+ copy._weak = true;
168+ }
74169 }
75170 return copy;
76171 }
77172
78173 const sanitized = contentDocs.map(sanitizeDoc);
79- console.log(`Prepared ${sanitized.length} content documents (from ${allDocs.length} total lines) for dataset "dev"...`);
174+ console.log(`Prepared ${sanitized.length} content documents and ${syndicationStubs.size} syndication stubs for dataset "dev"...`);
80175
81176 let activeToken = tokens[0];
82- async function mutateBatch(mutations) {
177+ async function mutateBatch(mutations, dataset = "dev" ) {
83178 for (let attempt = 0; attempt < 5; attempt++) {
84179 const token = activeToken || tokens[attempt % tokens.length];
85- const res = await fetch(`https://${projectId}.api.sanity.io/v2021-06-07/data/mutate/dev ?visibility=deferred&autoGenerateArrayKeys=true`, {
180+ const res = await fetch(`https://${projectId}.api.sanity.io/v2021-06-07/data/mutate/${dataset} ?visibility=deferred&autoGenerateArrayKeys=true`, {
86181 method: "POST",
87182 headers: {
88183 Authorization: `Bearer ${token}`,
@@ -105,20 +200,20 @@ jobs:
105200 await new Promise((r) => setTimeout(r, delay));
106201 continue;
107202 }
108- throw new Error(`Mutate batch failed: HTTP ${res.status}: ${errText}`);
203+ throw new Error(`Mutate batch (${dataset}) failed: HTTP ${res.status}: ${errText}`);
109204 }
110- throw new Error(" Mutate batch failed after 5 retries." );
205+ throw new Error(` Mutate batch (${dataset}) failed after 5 retries.` );
111206 }
112207
113- async function sendChunked(items, label) {
208+ async function sendChunked(items, label, op = "createOrReplace" ) {
114209 let batch = [];
115210 let batchBytes = 0;
116211 let sent = 0;
117212 const MAX_ITEMS = 200;
118213 const MAX_BYTES = 2500000;
119214
120215 for (const item of items) {
121- const mutation = { createOrReplace : item };
216+ const mutation = { [op] : item };
122217 const itemBytes = Buffer.byteLength(JSON.stringify(mutation), "utf8");
123218 if (batch.length > 0 && (batch.length >= MAX_ITEMS || batchBytes + itemBytes > MAX_BYTES)) {
124219 await mutateBatch(batch);
@@ -139,20 +234,135 @@ jobs:
139234 console.log(`[${label}] Completed (${sent} / ${items.length}).`);
140235 }
141236
237+ async function uploadMissingDevImages() {
238+ const q = encodeURIComponent(`*[_type == "sanity.imageAsset" && path match "images/${projectId}/dev/*"]._id`);
239+ let devUploadedIds = new Set();
240+ for (const token of tokens) {
241+ const res = await fetch(`https://${projectId}.api.sanity.io/v2025-02-19/data/query/dev?query=${q}`, {
242+ headers: { Authorization: `Bearer ${token}` },
243+ });
244+ if (res.ok) {
245+ const json = await res.json();
246+ devUploadedIds = new Set(json.result || []);
247+ break;
248+ }
249+ }
250+ console.log(`Found ${devUploadedIds.size} image binaries already in 'dev' CDN bucket.`);
251+
252+ const activeTypes = new Set(["post", "podcast", "guest", "author", "sponsor", "page", "settings", "podcastType"]);
253+ const priorityRefs = new Set();
254+ const inlineRefs = new Set();
255+
256+ function collectRefs(obj) {
257+ if (!obj || typeof obj !== "object") return;
258+ if (Array.isArray(obj)) return obj.forEach(collectRefs);
259+ if (typeof obj._ref === "string" && obj._ref.startsWith("image-")) {
260+ inlineRefs.add(obj._ref);
261+ }
262+ for (const v of Object.values(obj)) collectRefs(v);
263+ }
264+
265+ for (const doc of contentDocs) {
266+ if (!activeTypes.has(doc._type)) continue;
267+ if (doc.coverImage?.asset?._ref) priorityRefs.add(doc.coverImage.asset._ref);
268+ if (doc.photo?.asset?._ref) priorityRefs.add(doc.photo.asset._ref);
269+ if (doc.ogImage?.asset?._ref) priorityRefs.add(doc.ogImage.asset._ref);
270+ collectRefs(doc);
271+ }
272+
273+ const orderedRefs = [...new Set([...priorityRefs, ...inlineRefs])].filter(
274+ (id) => !devUploadedIds.has(id) && imageAssetMap.has(id)
275+ );
276+ console.log(`Uploading ${orderedRefs.length} missing active image binaries to 'dev' dataset...`);
277+
278+ let uploaded = 0;
279+ let idx = 0;
280+ const CONCURRENCY = 20;
281+
282+ async function worker() {
283+ while (idx < orderedRefs.length) {
284+ const currentIdx = idx++;
285+ const assetId = orderedRefs[currentIdx];
286+ const assetDoc = imageAssetMap.get(assetId);
287+ if (!assetDoc?.url) continue;
288+
289+ for (let attempt = 0; attempt < 4; attempt++) {
290+ try {
291+ const imgRes = await fetch(assetDoc.url);
292+ if (!imgRes.ok) break;
293+ const buf = Buffer.from(await imgRes.arrayBuffer());
294+ const token = activeToken || tokens[attempt % tokens.length];
295+ const filename = encodeURIComponent(assetDoc.originalFilename || `${assetId}.${assetDoc.extension || "jpg"}`);
296+ const upRes = await fetch(`https://${projectId}.api.sanity.io/v2021-06-07/assets/images/dev?filename=${filename}`, {
297+ method: "POST",
298+ headers: {
299+ Authorization: `Bearer ${token}`,
300+ "Content-Type": assetDoc.mimeType || imgRes.headers.get("content-type") || "image/jpeg",
301+ },
302+ body: buf,
303+ });
304+ if (upRes.ok) {
305+ activeToken = token;
306+ devUploadedIds.add(assetId);
307+ uploaded++;
308+ if (uploaded % 100 === 0 || uploaded === orderedRefs.length) {
309+ console.log(`[Image Binaries] Uploaded ${uploaded} / ${orderedRefs.length}...`);
310+ }
311+ break;
312+ }
313+ if (upRes.status === 401 || upRes.status === 403) {
314+ activeToken = tokens.find((t) => t !== token) || token;
315+ continue;
316+ }
317+ if (upRes.status === 429 || upRes.status >= 500) {
318+ await new Promise((r) => setTimeout(r, 500 * Math.pow(2, attempt)));
319+ continue;
320+ }
321+ break;
322+ } catch (err) {
323+ await new Promise((r) => setTimeout(r, 500));
324+ }
325+ }
326+ }
327+ }
328+
329+ await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
330+ console.log(`[Image Binaries] Finished uploading ${uploaded} image binaries to 'dev'.`);
331+ return devUploadedIds;
332+ }
333+
142334 (async () => {
143- const stubs = sanitized.map((d) => ({ _id: d._id, _type: d._type }));
144- await sendChunked(stubs, "Pass 1: Stubs");
145- await sendChunked(sanitized, "Pass 2: Content Docs");
335+ if (prodDeduplicateMutations.length > 0) {
336+ console.log(`Cleaning up ${prodDeduplicateMutations.length} duplicate syndications arrays in production...`);
337+ try {
338+ await mutateBatch(prodDeduplicateMutations, "production");
339+ console.log("Production syndications arrays deduplicated.");
340+ } catch (prodErr) {
341+ console.warn("Could not patch production syndications:", prodErr.message);
342+ }
343+ }
344+
345+ // Upload missing active image binaries to dev (creates real sanity.imageAsset docs with dev bucket paths)
346+ const devUploadedIds = await uploadMissingDevImages();
146347
147- const assetDocs = allDocs
148- .filter((d) => d._type === "sanity.imageAsset" || d._type === "sanity.fileAsset")
348+ // Import remaining asset metadata docs (only those not already uploaded as dev binaries) so all asset refs resolve
349+ const remainingAssetDocs = assetDocs
350+ .filter((d) => !devUploadedIds.has(d._id))
149351 .map(({ _rev, ...rest }) => rest);
150- try {
151- await sendChunked(assetDocs, "Pass 3: Asset Docs");
152- } catch (assetErr) {
153- console.warn("Note: Asset metadata docs skipped (served via CDN):", assetErr.message);
352+ if (remainingAssetDocs.length > 0) {
353+ await sendChunked(remainingAssetDocs, "Pass 1a: Remaining Asset Metadata", "createIfNotExists");
154354 }
155355
356+ // Create content + syndication stubs so all internal document references resolve as strong references
357+ const stubs = [
358+ ...sanitized.map((d) => ({ _id: d._id, _type: d._type })),
359+ ...Array.from(syndicationStubs.values()),
360+ ];
361+ await sendChunked(stubs, "Pass 1b: Document & Syndication Stubs", "createIfNotExists");
362+
363+ // Populate full content documents (with strong coverImage.asset & syndications references and unique _keys)
364+ await sendChunked(sanitized, "Pass 2: Content Docs", "createOrReplace");
365+
156366 console.log("Dataset sync completed successfully!");
157367 })().catch((err) => {
158368 console.error(err);
0 commit comments