← back to Letsbegin
Class-wide tag/entity cleanup on 1003-product cohort (decode &, strip color-name tags, lowercase+dedupe, lemma map)
857b11f49fe0e3a78dd54862219471974a8b7dd5 · 2026-06-11 13:48:48 -0700 · Steve Abrams
Files touched
A cohort-tag-cleanup.jsM package-lock.jsonM package.json
Diff
commit 857b11f49fe0e3a78dd54862219471974a8b7dd5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jun 11 13:48:48 2026 -0700
Class-wide tag/entity cleanup on 1003-product cohort (decode &, strip color-name tags, lowercase+dedupe, lemma map)
---
cohort-tag-cleanup.js | 394 ++++++++++++++++++++++++++++++++++++++++++++++++++
package-lock.json | 40 ++---
package.json | 2 +-
3 files changed, 415 insertions(+), 21 deletions(-)
diff --git a/cohort-tag-cleanup.js b/cohort-tag-cleanup.js
new file mode 100644
index 0000000..004eb60
--- /dev/null
+++ b/cohort-tag-cleanup.js
@@ -0,0 +1,394 @@
+#!/usr/bin/env node
+/**
+ * cohort-tag-cleanup.js — Class-wide, idempotent tag/entity cleanup for the
+ * last-20-day Shopify cohort (1,003 products) on the sandbox store.
+ *
+ * Four deterministic fixes applied to TITLE, product TAGS, and image ALT text.
+ * It does NOT touch cost/price, and does NOT touch the color metafields
+ * (custom.dominant_color / color_name / color_family). Read-only on those.
+ *
+ * FIX 1 HTML-entity decode: "&" and the mis-cased "&Amp;"/"&" -> "&"
+ * in title, every tag, and every image alt.
+ * FIX 2 Strip Title-Case (poetic) color-name tags from the tag array.
+ * A tag is stripped if it equals (case-insensitive) the product's
+ * color_name / dominant_color metafield, OR every token is drawn from
+ * a curated color vocabulary AND it is NOT a simple single-word basic
+ * color (those are kept) and NOT a structural/collection/style tag.
+ * FIX 3 Lowercase + case-insensitive dedupe of the whole tag array.
+ * FIX 4 Style-tag lemma map: singularize clear plurals + collapse synonyms
+ * (stripes->stripe, trees->tree, animals->animal, kids/childrens->
+ * children, leaves/foliage->leaf, flowers->flower, etc.).
+ *
+ * Order of operations per product:
+ * decode -> strip-color-tags -> lemma-map -> lowercase -> dedupe.
+ * Title-Case color match for FIX 2 runs on the DECODED tag (so "Black & White"
+ * is compared post-decode), but the simple-color KEEP and structural KEEP are
+ * applied before lowercasing so we can tell a poetic Title-Case color tag from
+ * a genuine lowercase style tag.
+ *
+ * IDEMPOTENT + RESUMABLE: a cleaned product produces identical output, so the
+ * mutation is skipped (no write) — safe to re-run any number of times.
+ *
+ * Usage:
+ * node cohort-tag-cleanup.js --limit 5 # validate: print before/after, NO writes unless --write
+ * node cohort-tag-cleanup.js --limit 5 --write # validate WITH writes on first 5
+ * node cohort-tag-cleanup.js --write # full cohort run (writes)
+ * node cohort-tag-cleanup.js # full cohort DRY-RUN (no writes)
+ *
+ * Env: SHOPIFY_ADMIN_TOKEN, SHOPIFY_STORE. PG* optional.
+ */
+
+const https = require('https');
+const { Client } = require('pg');
+
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || process.env.SHOPIFY_PRODUCT_TOKEN;
+const API = '/admin/api/2024-10/graphql.json';
+if (!TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN env var required'); process.exit(1); }
+
+const argv = process.argv.slice(2);
+const WRITE = argv.includes('--write');
+const limIdx = argv.indexOf('--limit');
+const LIMIT = limIdx >= 0 ? parseInt(argv[limIdx + 1], 10) : null;
+const VERBOSE = argv.includes('--verbose') || (LIMIT != null && LIMIT <= 10);
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+// ---------- Shopify GraphQL (helpers mirrored from enrich-write.js) ----------
+function gqlRaw(body) {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify(body);
+ const req = https.request({
+ hostname: STORE, path: API, method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
+ }, res => { let c = ''; res.on('data', d => c += d); res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); } }); });
+ req.on('error', reject);
+ req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
+ req.write(data); req.end();
+ });
+}
+async function gql(body, retries = 5) {
+ for (let attempt = 1; attempt <= retries; attempt++) {
+ try {
+ const result = await gqlRaw(body);
+ const throttled = result?.errors?.some(e => /Throttled/i.test(e.message || ''));
+ const lowBudget = (result?.extensions?.cost?.throttleStatus?.currentlyAvailable || 9999) < 200;
+ if (throttled) { await sleep(3000); continue; }
+ if (lowBudget) await sleep(1500);
+ return result;
+ } catch (e) {
+ if (attempt < retries) { await sleep(attempt * 2000); continue; }
+ throw e;
+ }
+ }
+}
+
+// ============================================================================
+// FIX 1 — HTML entity decode
+// ============================================================================
+// Decode & in any case (& &Amp; &) plus the numeric forms to "&".
+// Also handle double-encoding (&amp;) by looping until stable.
+function decodeEntities(s) {
+ if (s == null) return s;
+ let out = String(s);
+ let prev;
+ do {
+ prev = out;
+ out = out
+ .replace(/&/gi, '&') // & / &Amp; / &
+ .replace(/�*38;/g, '&') // &
+ .replace(/�*26;/gi, '&'); // &
+ } while (out !== prev);
+ return out;
+}
+
+// ============================================================================
+// Vocabulary for FIX 2 — color-name detection
+// ============================================================================
+// Color NOUNS (the head of a color name) — single lowercase ones are KEPT as
+// genuine facet tags; but they're also valid tokens inside a multi-word poetic
+// color name like "sage green" / "warm taupe".
+const COLOR_NOUNS = new Set([
+ 'red','orange','yellow','green','blue','purple','violet','pink','brown','black',
+ 'white','gray','grey','beige','tan','cream','navy','teal','olive','sage','taupe',
+ 'mauve','lavender','lilac','burgundy','crimson','scarlet','maroon','rust',
+ 'terracotta','peach','coral','salmon','rose','blush','ivory','charcoal','graphite',
+ 'slate','steel','cobalt','sapphire','indigo','periwinkle','turquoise','aqua','cyan',
+ 'mint','emerald','jade','forest','lime','mustard','amber','gold','golden','bronze',
+ 'copper','silver','platinum','pewter','stone','sand','khaki','camel','chocolate',
+ 'mahogany','walnut','espresso','caramel','honey','butter','lemon','marigold',
+ 'plum','aubergine','eggplant','magenta','fuchsia','raspberry','cherry','wine',
+ 'oxblood','brick','clay','ochre','umber','sepia','greige','oatmeal','linen',
+ 'porcelain','alabaster','snow','pearl','smoke','ash','dove','fog','mist','cloud',
+ 'midnight','onyx','jet','ebony','petrol','marine','ocean','sky','powder','denim',
+ 'cornflower','azure','cerulean','seafoam','pistachio','moss','fern','hunter',
+ 'army','avocado','chartreuse','citrus','tangerine','apricot','melon','blush',
+ 'multicolor','multi','neutral','natural','birch','tangerine','sunshine','sunflower',
+]);
+// Color MODIFIERS (adjectives that prefix a color noun in poetic names).
+const COLOR_MODS = new Set([
+ 'light','dark','deep','pale','soft','bright','muted','dusty','warm','cool','hot',
+ 'rich','vivid','vibrant','faded','washed','smoky','smokey','dusky','misty','hazy',
+ 'pastel','neon','electric','royal','classic','antique','vintage','burnt','toasted',
+ 'sun','sunny','frosted','icy','crisp','clean','pure','medium','mid','off','baby',
+ 'powder','dirty','ash','stone','steel','autumn','spring','summer','winter','dusk',
+ 'dawn','shadow','moody','jewel','earthy','earth','desert','tropical','arctic',
+]);
+// Single-word seasonal / poetic color_name leftovers that are NOT basic colors and
+// should be stripped only when they equal the metafield (handled by metafield match);
+// listed here so the vocab path also catches them if they slip through as tags.
+const POETIC_SINGLE = new Set(['spring','autumn','breeze','sunshine','haze','dusk','dawn','color','soft','dark','light','warm','cool','mist','fog','smoke','shadow','glow','sunset','sunrise']);
+
+// Simple BASIC single-word colors we KEEP as lowercase facet tags (standing rule).
+const KEEP_SIMPLE_COLORS = new Set([
+ 'red','orange','yellow','green','blue','purple','pink','brown','black','white',
+ 'gray','grey','beige','navy','teal','tan','cream','multi','multicolor','neutral',
+]);
+
+// Structural / system / collection tags that must NEVER be touched by the
+// color-strip logic (they survive into the lowercase+dedupe step).
+function isStructural(tag) {
+ return /^collection:/i.test(tag) ||
+ /^(mural|wall mural|non-woven|non woven|priced per square meter|rebel walls|cole & son|glitter walls|glitterwalls|panel|panels|wallcovering|commercial wallcovering|class a fire rated|display_variant|bundle|wallpaper-bundle|wallco-ai-bundle|wallco-original|digital-download|seamless-pattern|seamless-wallpaper|printable-wallpaper|wallpaper-download|home-decor|commercial-use|craft-pattern|curated-pattern|designer-wallpaper|pattern-collection|pattern-pack|spoonflower)$/i.test(tag) ||
+ /fire rated/i.test(tag) ||
+ /\.cs\.\d/i.test(tag) || // sku-like junk e.g. 95/1001.cs.0
+ /^colour_/i.test(tag) ||
+ /^ai-analyzed/i.test(tag);
+}
+
+// Tokenize a tag for vocab matching: lowercase, split on space/hyphen, drop "&" and "and".
+function colorTokens(tag) {
+ return tag.toLowerCase()
+ .replace(/&/g, ' ')
+ .split(/[\s\-]+/)
+ .map(t => t.trim())
+ .filter(t => t && t !== 'and');
+}
+
+// Is this tag a poetic color name (vocab path)? Multi-word where EVERY token is a
+// color noun or modifier (e.g. "Warm Taupe", "Sage Green", "Periwinkle Blue",
+// "Black & White"), OR a single poetic leftover. Single basic colors are NOT
+// flagged here (kept), and structural tags are never flagged.
+function isColorVocabTag(tag) {
+ if (isStructural(tag)) return false;
+ const toks = colorTokens(tag);
+ if (toks.length === 0) return false;
+ if (toks.length === 1) {
+ // single token: only strip if it's a poetic-single leftover, NOT a basic color
+ if (KEEP_SIMPLE_COLORS.has(toks[0])) return false;
+ return POETIC_SINGLE.has(toks[0]);
+ }
+ // multi-word: strip only if EVERY token is in the color vocabulary
+ return toks.every(t => COLOR_NOUNS.has(t) || COLOR_MODS.has(t) || POETIC_SINGLE.has(t));
+}
+
+// ============================================================================
+// FIX 4 — explicit lemma map (plurals -> singular, synonyms -> canonical).
+// Kept deliberately small + clear. Keys are lowercase; applied AFTER lowercasing.
+// ============================================================================
+const LEMMA = {
+ stripes: 'stripe',
+ trees: 'tree',
+ tiles: 'tile',
+ animals: 'animal',
+ flowers: 'flower',
+ florals: 'floral',
+ birds: 'bird',
+ leaves: 'leaf',
+ foliage: 'leaf',
+ shapes: 'shape',
+ motifs: 'motif',
+ arches: 'arch',
+ dogs: 'dog',
+ cats: 'cat',
+ // synonyms -> canonical
+ kids: 'children',
+ childrens: 'children',
+ 'kids-room': 'children',
+ illustrative: 'illustrated',
+ illustration: 'illustrated',
+ minimalist: 'minimal',
+ 'art-deco': 'art deco',
+ 'art deco': 'art deco',
+};
+function lemma(tag) {
+ return Object.prototype.hasOwnProperty.call(LEMMA, tag) ? LEMMA[tag] : tag;
+}
+
+// ============================================================================
+// Core transform: given raw tags + color name(s), produce the cleaned tag list.
+// Returns { tags, stripped, lemmatized, decodedTag } for change accounting.
+// ============================================================================
+function cleanTags(rawTags, colorNames) {
+ const colorSet = new Set(colorNames.filter(Boolean).map(c => decodeEntities(c).toLowerCase().trim()));
+ let strippedAny = false, lemmatizedAny = false, decodedTag = false;
+
+ // 1) decode entities on each tag
+ const decoded = rawTags.map(t => {
+ const d = decodeEntities(t);
+ if (d !== t) decodedTag = true;
+ return d;
+ });
+
+ // 2) strip color-name tags (metafield match OR vocab match) — but keep structural + simple colors
+ const afterStrip = decoded.filter(t => {
+ if (isStructural(t)) return true;
+ const low = t.toLowerCase().trim();
+ if (colorSet.has(low)) { strippedAny = true; return false; } // matches product's own color name
+ if (KEEP_SIMPLE_COLORS.has(low)) return true; // keep simple lowercase basic color
+ if (isColorVocabTag(t)) { strippedAny = true; return false; } // poetic color vocab tag
+ return true;
+ });
+
+ // 3) lowercase + lemma map
+ const mapped = afterStrip.map(t => {
+ const low = t.toLowerCase().trim();
+ const lem = lemma(low);
+ if (lem !== low) lemmatizedAny = true;
+ return lem;
+ });
+
+ // 4) case-insensitive dedupe (already lowercased), preserve first-seen order
+ const seen = new Set();
+ const final = [];
+ for (const t of mapped) {
+ if (!t) continue;
+ if (seen.has(t)) continue;
+ seen.add(t);
+ final.push(t);
+ }
+ return { tags: final, stripped: strippedAny, lemmatized: lemmatizedAny, decodedTag };
+}
+
+// ---------- product fetch / mutate ----------
+const PRODUCT_QUERY = (id) => ({ query: `{
+ product(id:"${id}") {
+ id title tags
+ media(first:30){ edges{ node{ ... on MediaImage { id alt } } } }
+ metafields(first:50){ edges{ node{ namespace key value } } }
+ }
+}` });
+const PRODUCT_UPDATE = 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message field } } }';
+
+async function processOne(id, counters) {
+ const fr = await gql(PRODUCT_QUERY(id));
+ const p = fr?.data?.product;
+ if (!p) { counters.notfound++; console.log(` ${id} NOT FOUND`); return; }
+
+ const mf = {};
+ for (const e of (p.metafields?.edges || [])) { if (e.node.namespace === 'custom') mf[e.node.key] = e.node.value; }
+ const colorNames = [mf.color_name, mf.dominant_color];
+
+ const origTitle = p.title || '';
+ const origTags = Array.isArray(p.tags) ? p.tags : [];
+
+ // --- title ---
+ const newTitle = decodeEntities(origTitle);
+ const titleChanged = newTitle !== origTitle;
+
+ // --- tags ---
+ const { tags: newTags, stripped, lemmatized, decodedTag } = cleanTags(origTags, colorNames);
+ const tagsChanged = newTags.length !== origTags.length || newTags.some((t, i) => t !== origTags[i]);
+
+ // --- image alts ---
+ const mediaEdges = p.media?.edges || [];
+ const altUpdates = [];
+ for (const e of mediaEdges) {
+ const n = e.node; if (!n?.id) continue;
+ const newAlt = decodeEntities(n.alt || '');
+ if (newAlt !== (n.alt || '')) altUpdates.push({ id: n.id, alt: newAlt });
+ }
+ const altChanged = altUpdates.length > 0;
+
+ // account per-fix (per product, count once each)
+ if (decodedTag || titleChanged || altChanged) counters.entityDecoded++;
+ if (stripped) counters.colorStripped++;
+ if (lemmatized) counters.lemmatized++;
+ // case-dedupe: did lowercasing/dedup change anything beyond strip/lemma/decode?
+ // detect by comparing a pure-lowercase-dedupe of origTags (post-decode) vs origTags
+ {
+ const decodedOrig = origTags.map(decodeEntities);
+ const lowSeen = new Set(); let caseChanged = false;
+ for (const t of decodedOrig) { const l = t.toLowerCase(); if (t !== l || lowSeen.has(l)) caseChanged = true; lowSeen.add(l); }
+ if (caseChanged) counters.caseDeduped++;
+ }
+
+ if (!titleChanged && !tagsChanged && !altChanged) { counters.noop++; if (VERBOSE) console.log(` ${id} no-op`); return; }
+
+ if (VERBOSE) {
+ console.log(` ${id}`);
+ if (titleChanged) console.log(` TITLE "${origTitle}" -> "${newTitle}"`);
+ if (tagsChanged) {
+ console.log(` TAGS before: [${origTags.join(', ')}]`);
+ console.log(` after: [${newTags.join(', ')}]`);
+ }
+ if (altChanged) altUpdates.forEach(a => console.log(` ALT -> "${a.alt}"`));
+ }
+
+ counters.changed++;
+ if (!WRITE) return;
+
+ // write title + tags
+ if (titleChanged || tagsChanged) {
+ const input = { id };
+ if (titleChanged) input.title = newTitle;
+ if (tagsChanged) input.tags = newTags;
+ const r = await gql({ query: PRODUCT_UPDATE, variables: { i: input } });
+ const errs = r?.data?.productUpdate?.userErrors || [];
+ if (errs.length) { counters.errors++; console.log(` ${id} TITLE/TAGS ERR: ${errs.map(e => e.message).join('; ')}`); }
+ await sleep(350);
+ }
+ // write alts
+ if (altChanged) {
+ const mediaJson = altUpdates.map(m => `{id: "${m.id}", alt: "${m.alt.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"}`).join(', ');
+ const r = await gql({ query: `mutation { productUpdateMedia(productId: "${id}", media: [${mediaJson}]) { media { ... on MediaImage { id } } mediaUserErrors { message } } }` });
+ const errs = r?.data?.productUpdateMedia?.mediaUserErrors || [];
+ if (errs.length) { counters.errors++; console.log(` ${id} ALT ERR: ${errs.map(e => e.message).join('; ')}`); }
+ await sleep(350);
+ }
+}
+
+async function main() {
+ // cohort from PG
+ const pg = new Client({ user: process.env.PGUSER || 'stevestudio2', database: process.env.PGDATABASE || 'dw_unified', host: process.env.PGHOST || '/tmp' });
+ await pg.connect();
+ const res = await pg.query(`SELECT shopify_id FROM shopify_products WHERE created_at_shopify >= now()-interval '20 days' ORDER BY created_at_shopify DESC`);
+ await pg.end();
+ let ids = res.rows.map(r => r.shopify_id).filter(Boolean);
+ // --ids "id1,id2" restricts to specific cohort members (validation aid; still must be in cohort)
+ const idsIdx = argv.indexOf('--ids');
+ if (idsIdx >= 0 && argv[idsIdx + 1]) {
+ const want = new Set(argv[idsIdx + 1].split(',').map(s => {
+ const t = s.trim();
+ return /^gid:\/\//.test(t) ? t : `gid://shopify/Product/${t.replace(/\D/g, '')}`;
+ }));
+ ids = ids.filter(id => want.has(id));
+ }
+ if (LIMIT != null) ids = ids.slice(0, LIMIT);
+
+ console.log(`cohort-tag-cleanup: ${ids.length} products | WRITE=${WRITE ? 'YES' : 'DRY-RUN'}${LIMIT != null ? ` | limit=${LIMIT}` : ''}`);
+ const counters = { changed: 0, noop: 0, notfound: 0, errors: 0, entityDecoded: 0, colorStripped: 0, caseDeduped: 0, lemmatized: 0 };
+
+ let n = 0;
+ for (const id of ids) {
+ n++;
+ try { await processOne(id, counters); }
+ catch (e) { counters.errors++; console.log(` ${id} EXC: ${e.message}`); }
+ if (n % 50 === 0) console.log(` ...${n}/${ids.length} (changed=${counters.changed} noop=${counters.noop} err=${counters.errors})`);
+ await sleep(120); // ~2 req/s pacing baseline; gql() adds backoff
+ }
+
+ console.log('\n=== SUMMARY ===');
+ console.log(`total: ${ids.length}`);
+ console.log(`changed: ${counters.changed}`);
+ console.log(`no-op (clean): ${counters.noop}`);
+ console.log(`not found: ${counters.notfound}`);
+ console.log(`errors: ${counters.errors}`);
+ console.log('--- per-fix product counts (pre-clean state) ---');
+ console.log(`entity-decoded: ${counters.entityDecoded}`);
+ console.log(`color-tags strip: ${counters.colorStripped}`);
+ console.log(`case-deduped: ${counters.caseDeduped}`);
+ console.log(`lemmatized: ${counters.lemmatized}`);
+}
+
+main().catch(e => { console.error(`FATAL: ${e.message}\n${e.stack}`); process.exit(1); });
diff --git a/package-lock.json b/package-lock.json
index d8b2647..c24f9b7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,7 @@
"framer-motion": "^12.26.1",
"lucide-react": "^0.562.0",
"next": "^16.0.3",
- "pg": "^8.16.3",
+ "pg": "^8.21.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-resizable-panels": "^4.4.0",
@@ -1927,14 +1927,14 @@
}
},
"node_modules/pg": {
- "version": "8.16.3",
- "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
- "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
+ "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"license": "MIT",
"dependencies": {
- "pg-connection-string": "^2.9.1",
- "pg-pool": "^3.10.1",
- "pg-protocol": "^1.10.3",
+ "pg-connection-string": "^2.13.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.14.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
@@ -1942,7 +1942,7 @@
"node": ">= 16.0.0"
},
"optionalDependencies": {
- "pg-cloudflare": "^1.2.7"
+ "pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
@@ -1954,16 +1954,16 @@
}
},
"node_modules/pg-cloudflare": {
- "version": "1.2.7",
- "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
- "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
- "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
+ "version": "2.13.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
+ "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
"license": "MIT"
},
"node_modules/pg-int8": {
@@ -1976,18 +1976,18 @@
}
},
"node_modules/pg-pool": {
- "version": "3.10.1",
- "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
- "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
- "version": "1.10.3",
- "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
- "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
+ "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
"license": "MIT"
},
"node_modules/pg-types": {
diff --git a/package.json b/package.json
index 9b361d3..c1cc7f1 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,7 @@
"framer-motion": "^12.26.1",
"lucide-react": "^0.562.0",
"next": "^16.0.3",
- "pg": "^8.16.3",
+ "pg": "^8.21.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-resizable-panels": "^4.4.0",
← 9b884d7 Add color_family controlled-vocab metafield from vendor moti
·
back to Letsbegin
·
Write C3 per-m2 cost ($44.50) metafield on 1000 Rebel Walls 62605f8 →