diff --git a/system/netlify/functions/keep-confirm.mjs b/system/netlify/functions/keep-confirm.mjs --- a/system/netlify/functions/keep-confirm.mjs +++ b/system/netlify/functions/keep-confirm.mjs @@ -4,6 +4,7 @@ // POST /api/keep-confirm - Record a successful client-side wallet mint // This is called after the user signs the transaction with their wallet // to update the kidlisp record with mint information. +import crypto from "crypto"; import { authorize } from "../../backend/authorization.mjs"; import { connect } from "../../backend/database.mjs"; import { respond } from "../../backend/http.mjs"; @@ -342,12 +343,24 @@ } // upsert: pieces that live only in Datomic (KIDLISP_DATOMIC=on) don't // yet have a Mongo row on first keep. Writing on upsert creates one so - // ipfsMedia / kept persist. + // ipfsMedia / kept persist. Seed the row's identity fields on insert from + // the Datomic record so it never lands as a hash-less { hash: null } doc. + const setOnInsert = {}; + if (typeof record?.source === "string" && record.source.length > 0) { + setOnInsert.source = record.source; + setOnInsert.hash = crypto + .createHash("sha256") + .update(record.source.trim()) + .digest("hex"); + setOnInsert.when = now; + } + const updateResult = await collection.updateOne( { code: cleanPiece }, { $set: setOps, ...(Object.keys(unsetOps).length > 0 ? { $unset: unsetOps } : {}), + ...(Object.keys(setOnInsert).length > 0 ? { $setOnInsert: setOnInsert } : {}), }, { upsert: true } ); diff --git a/system/netlify/functions/store-kidlisp.mjs b/system/netlify/functions/store-kidlisp.mjs --- a/system/netlify/functions/store-kidlisp.mjs +++ b/system/netlify/functions/store-kidlisp.mjs @@ -64,12 +64,31 @@ background: true, name: 'kidlisp_code_unique' }); - // Create unique index on hash field for deduplication - await collection.createIndex({ hash: 1 }, { - unique: true, + // Create unique index on hash field for deduplication. + // PARTIAL on string hashes only: rows without a real hash (e.g. Datomic-only + // pieces upserted by keep-confirm) must NOT all collide on { hash: null }. + const hashIndexOptions = { + unique: true, background: true, - name: 'kidlisp_hash_unique' - }); + name: 'kidlisp_hash_unique', + partialFilterExpression: { hash: { $type: 'string' } }, + }; + try { + await collection.createIndex({ hash: 1 }, hashIndexOptions); + } catch (err) { + // A pre-existing plain-unique index has different options — migrate it. + const conflict = + err?.code === 85 || err?.code === 86 || + err?.codeName === 'IndexOptionsConflict' || + err?.codeName === 'IndexKeySpecsConflict'; + if (conflict) { + console.warn('♻️ Migrating kidlisp_hash_unique → partial unique index…'); + await collection.dropIndex('kidlisp_hash_unique'); + await collection.createIndex({ hash: 1 }, hashIndexOptions); + } else { + throw err; + } + } // Create index on when field for analytics/cleanup queries await collection.createIndex({ when: 1 }, {