Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

kidlisp(datomic): dual-write mints/media to the sidecar

After the 2026-03-24 backfill, every keep-*.mjs write still landed
only in Mongo even though reads had cut over to Datomic. New mints,
rebakes, and metadata updates were invisible to keep.kidlisp.com —
the page showed a frozen snapshot of the pre-cutover kept set.

Mirror each Mongo mutation into the sidecar behind the existing
KIDLISP_DATOMIC flag:
- keep-confirm → sidecar.recordMint (source="kept")
- keep-update-confirm → sidecar.recordMint (source="update")
- keep-mint → setIpfsMedia + setPendingRebake; server-mint
path also records the mint and tezos-state
- keep-prepare-bg → setIpfsMedia + setPendingRebake
- keep-update → recordMint on the deprecated server-sign path

All sidecar calls are fire-and-forget (errors logged, Mongo remains the
primary write). selectPrimaryKeep now prefers the freshest keptAt so
metadata updates surface immediately in the Mongo-shape response.

catchup-kidlisp-to-datomic.mjs replays the missed window idempotently:
it queries Datomic for each piece's existing keeps and only sends the
delta, then force-refreshes ipfsMedia / pendingRebake / tezos-state
(replace semantics on the sidecar side).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+616 -136
+287
system/backend/catchup-kidlisp-to-datomic.mjs
··· 1 + // Catch-up sync: Mongo kidlisp → Datomic sidecar. 2 + // 3 + // Unlike backfill-kidlisp-to-datomic.mjs, this script is idempotent: for 4 + // each piece it checks what's already in Datomic and only sends the 5 + // delta. Intended to be run any time the sidecar has missed writes 6 + // (e.g., everything between the 2026-03-24 backfill and the dual-write 7 + // rollout). 8 + // 9 + // Env: 10 + // SIDECAR_URL default http://127.0.0.1:8891 11 + // CLIENT_SECRET required 12 + // SINCE default 2026-03-24T00:00:00Z — low-water mark for 13 + // filtering Mongo docs. Only docs touched after this 14 + // (by `when`, `kept.keptAt`, `ipfsMedia.createdAt`, 15 + // `pendingRebake.createdAt`, or a contract mint date) 16 + // are processed. 17 + // DRY_RUN if "true", logs intended writes without sending 18 + // ONLY_CODE optional — process a single piece by code 19 + // 20 + // Usage (from silo): 21 + // SIDECAR_URL=http://127.0.0.1:8891 \ 22 + // CLIENT_SECRET=... \ 23 + // node system/backend/catchup-kidlisp-to-datomic.mjs 24 + 25 + import { connect } from "./database.mjs"; 26 + 27 + const SIDECAR_URL = process.env.SIDECAR_URL || "http://127.0.0.1:8891"; 28 + const CLIENT_SECRET = process.env.CLIENT_SECRET; 29 + const DRY_RUN = process.env.DRY_RUN === "true"; 30 + const SINCE = new Date(process.env.SINCE || "2026-03-24T00:00:00Z"); 31 + const ONLY_CODE = process.env.ONLY_CODE || null; 32 + 33 + if (!DRY_RUN && !CLIENT_SECRET) { 34 + console.error("CLIENT_SECRET is required (or set DRY_RUN=true)"); 35 + process.exit(1); 36 + } 37 + 38 + function sidecarHeaders() { 39 + return { 40 + "content-type": "application/json", 41 + "x-sidecar-secret": CLIENT_SECRET, 42 + }; 43 + } 44 + 45 + async function sidecarReq(method, path, body) { 46 + if (DRY_RUN && method !== "GET") { 47 + return { ok: true, status: 200, body: { dryRun: true } }; 48 + } 49 + const res = await fetch(`${SIDECAR_URL}${path}`, { 50 + method, 51 + headers: sidecarHeaders(), 52 + body: body != null ? JSON.stringify(body) : undefined, 53 + }); 54 + const text = await res.text(); 55 + let json = null; 56 + try { json = text ? JSON.parse(text) : null; } catch { /* not JSON */ } 57 + return { ok: res.ok, status: res.status, body: json ?? text }; 58 + } 59 + 60 + function normInstant(value) { 61 + if (!value) return null; 62 + if (value instanceof Date) return value.toISOString(); 63 + if (typeof value === "string") return value; 64 + if (typeof value === "number") return new Date(value).toISOString(); 65 + return null; 66 + } 67 + 68 + function normalizeKeep(k, defaults = {}) { 69 + const tokenId = Number(k?.tokenId); 70 + if (!Number.isInteger(tokenId) || tokenId < 0) return null; 71 + const contractAddress = k?.contractAddress || defaults.contractAddress || null; 72 + if (!contractAddress) return null; 73 + return { 74 + tokenId, 75 + contractAddress, 76 + network: k?.network || defaults.network || "mainnet", 77 + txHash: k?.txHash || defaults.txHash || null, 78 + contractProfile: k?.contractProfile || k?.profile || defaults.contractProfile || null, 79 + contractVersion: k?.contractVersion || k?.version || defaults.contractVersion || null, 80 + keptAt: normInstant(k?.keptAt || k?.mintedAt || defaults.keptAt), 81 + keptBy: k?.keptBy || defaults.keptBy || null, 82 + walletAddress: k?.walletAddress || k?.owner || defaults.walletAddress || null, 83 + artifactUri: k?.artifactUri || defaults.artifactUri || null, 84 + thumbnailUri: k?.thumbnailUri || defaults.thumbnailUri || null, 85 + metadataUri: k?.metadataUri || defaults.metadataUri || null, 86 + source: defaults.source || "catchup", 87 + }; 88 + } 89 + 90 + function existingKeepKey(k) { 91 + return `${k?.tokenId}::${(k?.contractAddress || "").toLowerCase()}::${k?.txHash || ""}`; 92 + } 93 + 94 + function docTouchedSince(doc, since) { 95 + const t = since.getTime(); 96 + const dates = [ 97 + doc.when, 98 + doc.kept?.keptAt, 99 + doc.ipfsMedia?.createdAt, 100 + doc.pendingRebake?.createdAt, 101 + ]; 102 + if (doc.tezos?.contracts && typeof doc.tezos.contracts === "object") { 103 + for (const v of Object.values(doc.tezos.contracts)) { 104 + if (v?.mintedAt) dates.push(v.mintedAt); 105 + if (v?.lastUpdatedAt) dates.push(v.lastUpdatedAt); 106 + if (v?.lastConfirmAt) dates.push(v.lastConfirmAt); 107 + } 108 + } 109 + for (const d of dates) { 110 + if (!d) continue; 111 + const dt = new Date(d).getTime(); 112 + if (Number.isFinite(dt) && dt >= t) return true; 113 + } 114 + return false; 115 + } 116 + 117 + async function ensureEntity(doc, stats) { 118 + // Idempotent by hash — if present, sidecar bumps hits and returns code. 119 + const res = await sidecarReq("POST", "/kidlisp", { 120 + code: doc.code, 121 + source: doc.source, 122 + hash: doc.hash, 123 + user_sub: doc.user || null, 124 + when: doc.when ? new Date(doc.when).toISOString() : null, 125 + hits: typeof doc.hits === "number" ? doc.hits : 1, 126 + }); 127 + if (!res.ok) { 128 + stats.errors++; 129 + console.error(` ! ensure failed ${doc.code}: ${res.status} ${JSON.stringify(res.body)}`); 130 + return false; 131 + } 132 + return true; 133 + } 134 + 135 + async function syncIpfsMedia(doc, stats) { 136 + if (!doc.ipfsMedia) return; 137 + const res = await sidecarReq("POST", `/kidlisp/${doc.code}/ipfs-media`, { 138 + artifactUri: doc.ipfsMedia.artifactUri || null, 139 + thumbnailUri: doc.ipfsMedia.thumbnailUri || null, 140 + sourceHash: doc.ipfsMedia.sourceHash || null, 141 + authorHandle: doc.ipfsMedia.authorHandle || null, 142 + depCount: doc.ipfsMedia.depCount ?? null, 143 + packDate: doc.ipfsMedia.packDate || null, 144 + createdAt: normInstant(doc.ipfsMedia.createdAt), 145 + }); 146 + if (res.ok) stats.ipfs++; 147 + else { 148 + stats.errors++; 149 + console.error(` ! ipfs-media ${doc.code}: ${res.status}`); 150 + } 151 + } 152 + 153 + async function syncPendingRebake(doc, stats) { 154 + if (!doc.pendingRebake) return; 155 + const res = await sidecarReq("POST", `/kidlisp/${doc.code}/pending-rebake`, doc.pendingRebake); 156 + if (res.ok) stats.pendingRebake++; 157 + else { 158 + stats.errors++; 159 + console.error(` ! pending-rebake ${doc.code}: ${res.status}`); 160 + } 161 + } 162 + 163 + async function syncTezosState(doc, stats) { 164 + if (!doc.tezos || typeof doc.tezos !== "object") return; 165 + const t = doc.tezos; 166 + const res = await sidecarReq("POST", `/kidlisp/${doc.code}/tezos-state`, { 167 + minted: !!t.minted, 168 + exists: !!t.exists, 169 + tokenId: t.tokenId ?? null, 170 + txHash: t.txHash ?? null, 171 + creatorAddress: t.creatorAddress ?? null, 172 + codeHash: t.codeHash ?? null, 173 + network: t.network ?? null, 174 + reason: t.reason ?? null, 175 + error: t.error ?? null, 176 + }); 177 + if (res.ok) stats.tezos++; 178 + else { 179 + stats.errors++; 180 + console.error(` ! tezos-state ${doc.code}: ${res.status}`); 181 + } 182 + } 183 + 184 + async function syncKeeps(doc, stats) { 185 + // Collect candidate keeps from the Mongo doc 186 + const candidates = []; 187 + if (doc.kept) { 188 + const k = normalizeKeep(doc.kept, { source: "kept" }); 189 + if (k) candidates.push(k); 190 + } 191 + if (doc.tezos?.contracts && typeof doc.tezos.contracts === "object") { 192 + for (const [contractAddress, v] of Object.entries(doc.tezos.contracts)) { 193 + if (!v || typeof v !== "object" || !v.minted) continue; 194 + const k = normalizeKeep(v, { 195 + source: "contract_keyed", 196 + contractAddress, 197 + keptAt: v.mintedAt, 198 + }); 199 + if (k) candidates.push(k); 200 + } 201 + } 202 + if (candidates.length === 0) return; 203 + 204 + // Ask datomic what it already has so we skip duplicates. 205 + let existing = new Set(); 206 + if (!DRY_RUN) { 207 + const lookup = await sidecarReq("GET", `/kidlisp/${doc.code}`); 208 + if (lookup.ok && lookup.body?.keeps) { 209 + for (const k of lookup.body.keeps) existing.add(existingKeepKey(k)); 210 + } 211 + } 212 + 213 + for (const k of candidates) { 214 + if (existing.has(existingKeepKey(k))) { 215 + stats.keepsSkipped++; 216 + continue; 217 + } 218 + const res = await sidecarReq("POST", `/kidlisp/${doc.code}/mint`, k); 219 + if (res.ok) stats.keeps++; 220 + else { 221 + stats.errors++; 222 + console.error(` ! mint ${doc.code} token ${k.tokenId}: ${res.status} ${JSON.stringify(res.body)}`); 223 + } 224 + } 225 + } 226 + 227 + async function processDoc(doc, stats) { 228 + stats.processed++; 229 + const ok = await ensureEntity(doc, stats); 230 + if (!ok) return; 231 + await syncIpfsMedia(doc, stats); 232 + await syncPendingRebake(doc, stats); 233 + await syncTezosState(doc, stats); 234 + await syncKeeps(doc, stats); 235 + } 236 + 237 + async function main() { 238 + const started = Date.now(); 239 + console.log(`▶ catchup start — dryRun=${DRY_RUN} sidecar=${SIDECAR_URL} since=${SINCE.toISOString()}`); 240 + 241 + const database = await connect(); 242 + const coll = database.db.collection("kidlisp"); 243 + 244 + // Broad filter then precise check — picks up docs that were touched 245 + // after SINCE via any of the mutable sub-fields. 246 + const filter = ONLY_CODE 247 + ? { code: ONLY_CODE } 248 + : { 249 + $or: [ 250 + { when: { $gte: SINCE } }, 251 + { "kept.keptAt": { $gte: SINCE } }, 252 + { "ipfsMedia.createdAt": { $gte: SINCE } }, 253 + { "pendingRebake.createdAt": { $gte: SINCE } }, 254 + { "tezos.mintedAt": { $gte: SINCE } }, 255 + ], 256 + }; 257 + 258 + const total = await coll.countDocuments(filter); 259 + console.log(` candidate docs: ${total}`); 260 + 261 + const cursor = coll.find(filter).sort({ when: 1 }).batchSize(200); 262 + 263 + const stats = { 264 + processed: 0, ensured: 0, keeps: 0, keepsSkipped: 0, 265 + ipfs: 0, pendingRebake: 0, tezos: 0, errors: 0, 266 + }; 267 + 268 + let last = Date.now(); 269 + for await (const doc of cursor) { 270 + if (!docTouchedSince(doc, SINCE) && !ONLY_CODE) continue; 271 + await processDoc(doc, stats); 272 + if (Date.now() - last > 3000) { 273 + console.log(` ${stats.processed} — ${JSON.stringify(stats)}`); 274 + last = Date.now(); 275 + } 276 + } 277 + 278 + await database.disconnect(); 279 + const secs = ((Date.now() - started) / 1000).toFixed(1); 280 + console.log(`✓ catchup done in ${secs}s — ${JSON.stringify(stats)}`); 281 + if (stats.errors > 0) process.exit(1); 282 + } 283 + 284 + main().catch((err) => { 285 + console.error("✗ catchup fatal:", err); 286 + process.exit(1); 287 + });
+116
system/backend/kidlisp-dual-write.mjs
··· 1 + // Dual-write helpers for the kidlisp Datomic cutover. 2 + // 3 + // Each function mirrors a Mongo write into the sidecar when 4 + // KIDLISP_DATOMIC=on. They are fire-and-forget from the caller's 5 + // perspective: errors are logged and swallowed so that the live 6 + // Mongo write remains the source of truth for request success. 7 + // 8 + // Gate these calls on `kidlispDatomicEnabled()` in the caller so we 9 + // don't pay for the sidecar round-trip when the flag is off. 10 + 11 + import { sidecar, kidlispDatomicEnabled } from "./kidlisp-sidecar.mjs"; 12 + 13 + function normalizeInstant(value) { 14 + if (!value) return null; 15 + if (value instanceof Date) return value.toISOString(); 16 + if (typeof value === "string") return value; 17 + if (typeof value === "number") return new Date(value).toISOString(); 18 + return null; 19 + } 20 + 21 + function swallow(label, code) { 22 + return (err) => { 23 + const msg = err?.message || String(err); 24 + console.warn(`⚠️ sidecar ${label} for $${code} failed: ${msg}`); 25 + }; 26 + } 27 + 28 + // Record a mint in Datomic. No-ops without a tokenId or contract address, 29 + // since the sidecar's record-mint endpoint requires both. `source` labels 30 + // the origin (kept, update, server_mint, contract_keyed, …) so the primary 31 + // keep selector can prefer the freshest record. 32 + export async function mirrorRecordMint(code, keep, { source = "kept" } = {}) { 33 + if (!kidlispDatomicEnabled()) return; 34 + if (!code || !keep) return; 35 + const tokenId = Number(keep.tokenId); 36 + if (!Number.isInteger(tokenId) || tokenId < 0) return; 37 + const contractAddress = keep.contractAddress || null; 38 + if (!contractAddress) return; 39 + 40 + const body = { 41 + tokenId, 42 + contractAddress, 43 + network: keep.network || "mainnet", 44 + txHash: keep.txHash || null, 45 + contractProfile: keep.contractProfile || null, 46 + contractVersion: keep.contractVersion || null, 47 + keptAt: normalizeInstant(keep.keptAt || keep.mintedAt || new Date()), 48 + keptBy: keep.keptBy || null, 49 + walletAddress: keep.walletAddress || keep.owner || null, 50 + artifactUri: keep.artifactUri || null, 51 + thumbnailUri: keep.thumbnailUri || null, 52 + metadataUri: keep.metadataUri || null, 53 + source, 54 + }; 55 + 56 + try { 57 + await sidecar.recordMint(code, body); 58 + } catch (err) { 59 + swallow("recordMint", code)(err); 60 + } 61 + } 62 + 63 + export async function mirrorIpfsMedia(code, media) { 64 + if (!kidlispDatomicEnabled()) return; 65 + if (!code || !media) return; 66 + try { 67 + await sidecar.setIpfsMedia(code, { 68 + artifactUri: media.artifactUri || null, 69 + thumbnailUri: media.thumbnailUri || null, 70 + sourceHash: media.sourceHash || null, 71 + authorHandle: media.authorHandle || null, 72 + depCount: media.depCount ?? null, 73 + packDate: media.packDate || null, 74 + createdAt: normalizeInstant(media.createdAt), 75 + }); 76 + } catch (err) { 77 + swallow("setIpfsMedia", code)(err); 78 + } 79 + } 80 + 81 + export async function mirrorPendingRebake(code, rebake) { 82 + if (!kidlispDatomicEnabled()) return; 83 + if (!code || !rebake) return; 84 + try { 85 + await sidecar.setPendingRebake(code, { 86 + artifactUri: rebake.artifactUri || null, 87 + thumbnailUri: rebake.thumbnailUri || null, 88 + metadataUri: rebake.metadataUri || null, 89 + contractAddress: rebake.contractAddress || null, 90 + contractProfile: rebake.contractProfile || null, 91 + contractVersion: rebake.contractVersion || null, 92 + }); 93 + } catch (err) { 94 + swallow("setPendingRebake", code)(err); 95 + } 96 + } 97 + 98 + export async function mirrorTezosState(code, state) { 99 + if (!kidlispDatomicEnabled()) return; 100 + if (!code || !state) return; 101 + try { 102 + await sidecar.setTezosState(code, { 103 + minted: !!state.minted, 104 + exists: !!state.exists, 105 + tokenId: state.tokenId ?? null, 106 + txHash: state.txHash ?? null, 107 + creatorAddress: state.creatorAddress ?? null, 108 + codeHash: state.codeHash ?? null, 109 + network: state.network ?? null, 110 + reason: state.reason ?? null, 111 + error: state.error ?? null, 112 + }); 113 + } catch (err) { 114 + swallow("setTezosState", code)(err); 115 + } 116 + }
+5
system/netlify/functions/keep-confirm.mjs
··· 8 8 import { connect } from "../../backend/database.mjs"; 9 9 import { respond } from "../../backend/http.mjs"; 10 10 import { getKeepsContractAddress, LEGACY_KEEPS_CONTRACT } from "../../backend/tezos-keeps-contract.mjs"; 11 + import { mirrorRecordMint } from "../../backend/kidlisp-dual-write.mjs"; 11 12 12 13 const VERSION_BY_PROFILE = { 13 14 v11: "11.0.0", ··· 350 351 console.warn(`❌ Failed to update piece ${cleanPiece}`); 351 352 await database.disconnect(); 352 353 return respond(500, { error: "Failed to record mint" }); 354 + } 355 + 356 + if (resolvedTokenId !== null) { 357 + await mirrorRecordMint(cleanPiece, setOps.kept, { source: "kept" }); 353 358 } 354 359 355 360 console.log(`✅ Recorded keep for $${cleanPiece} - Token #${resolvedTokenId ?? "pending"} on ${normalizedNetwork}`);
+43 -13
system/netlify/functions/keep-mint.mjs
··· 16 16 import { authorize, handleFor, hasAdmin } from "../../backend/authorization.mjs"; 17 17 import { connect } from "../../backend/database.mjs"; 18 18 import { analyzeKidLisp, ANALYZER_VERSION } from "../../backend/kidlisp-analyzer.mjs"; 19 + import { 20 + mirrorIpfsMedia, 21 + mirrorPendingRebake, 22 + mirrorRecordMint, 23 + mirrorTezosState, 24 + } from "../../backend/kidlisp-dual-write.mjs"; 19 25 import { stream } from "@netlify/functions"; 20 26 import { TezosToolkit } from "@taquito/taquito"; 21 27 import { InMemorySigner } from "@taquito/signer"; ··· 837 843 } 838 844 839 845 await collection.updateOne({ code: pieceName }, updateOps); 846 + await mirrorIpfsMedia(pieceName, updateOps.$set.ipfsMedia); 840 847 console.log(`🪙 KEEP: Cached IPFS media for $${pieceName}`); 841 - 848 + 842 849 // REBAKE MODE: Return early with new URIs (don't proceed to minting) 843 850 if (isRebake) { 844 851 // Store pending rebake info so it persists across page refreshes 852 + const rebakePayload = { 853 + artifactUri, 854 + thumbnailUri, 855 + createdAt: new Date(), 856 + sourceHash: pieceSourceHash, 857 + }; 845 858 await collection.updateOne( 846 859 { code: pieceName }, 847 - { 848 - $set: { 849 - pendingRebake: { 850 - artifactUri, 851 - thumbnailUri, 852 - createdAt: new Date(), 853 - sourceHash: pieceSourceHash, 854 - } 855 - } 856 - } 860 + { $set: { pendingRebake: rebakePayload } } 857 861 ); 862 + await mirrorPendingRebake(pieceName, rebakePayload); 858 863 console.log(`🪙 KEEP: Stored pending rebake for $${pieceName}`); 859 864 860 865 const rebakeCreatedAt = new Date().toISOString(); ··· 1104 1109 const piecesCollection = database.db.collection("kidlisp"); 1105 1110 await piecesCollection.updateOne( 1106 1111 { user: user.sub, code: pieceName }, 1107 - { 1108 - $set: { 1112 + { 1113 + $set: { 1109 1114 [`tezos.contracts.${CONTRACT_ADDRESS}`]: { 1110 1115 minted: true, 1111 1116 tokenId: tokenId, ··· 1121 1126 } 1122 1127 } 1123 1128 ); 1129 + 1130 + await mirrorRecordMint( 1131 + pieceName, 1132 + { 1133 + tokenId, 1134 + contractAddress: CONTRACT_ADDRESS, 1135 + network: NETWORK, 1136 + txHash: op.hash, 1137 + keptAt: new Date(), 1138 + keptBy: user.sub, 1139 + walletAddress: destinationAddress, 1140 + artifactUri, 1141 + thumbnailUri, 1142 + metadataUri, 1143 + }, 1144 + { source: "server_mint" }, 1145 + ); 1146 + await mirrorTezosState(pieceName, { 1147 + minted: true, 1148 + exists: true, 1149 + tokenId, 1150 + txHash: op.hash, 1151 + creatorAddress: creatorWalletAddress, 1152 + network: NETWORK, 1153 + }); 1124 1154 1125 1155 await send("complete", { 1126 1156 success: true,
+19 -16
system/netlify/functions/keep-prepare-background.mjs
··· 11 11 import { getKeepsContractAddress, LEGACY_KEEPS_CONTRACT } from "../../backend/tezos-keeps-contract.mjs"; 12 12 import { handleFor } from "../../backend/authorization.mjs"; 13 13 import { 14 + mirrorIpfsMedia, 15 + mirrorPendingRebake, 16 + } from "../../backend/kidlisp-dual-write.mjs"; 17 + import { 14 18 updateJobStage, 15 19 setJobResult, 16 20 markJobReady, ··· 673 677 }; 674 678 } 675 679 await col.updateOne({ code: pieceName }, updateOps); 680 + await mirrorIpfsMedia(pieceName, updateOps.$set.ipfsMedia); 676 681 } 677 682 678 683 // ── Rebake early exit ────────────────────────────────────────────── 679 684 if (isRebake) { 685 + const rebakePayload = { 686 + artifactUri, 687 + thumbnailUri, 688 + metadataUri: null, 689 + createdAt: new Date(), 690 + sourceHash: pieceSourceHash, 691 + network: NETWORK, 692 + contractAddress: CONTRACT_ADDRESS, 693 + contractProfile: contractProfile || null, 694 + contractVersion: contractVersion || null, 695 + packDate: packDate || null, 696 + }; 680 697 await col.updateOne( 681 698 { code: pieceName }, 682 - { 683 - $set: { 684 - pendingRebake: { 685 - artifactUri, 686 - thumbnailUri, 687 - metadataUri: null, 688 - createdAt: new Date(), 689 - sourceHash: pieceSourceHash, 690 - network: NETWORK, 691 - contractAddress: CONTRACT_ADDRESS, 692 - contractProfile: contractProfile || null, 693 - contractVersion: contractVersion || null, 694 - packDate: packDate || null, 695 - }, 696 - }, 697 - } 699 + { $set: { pendingRebake: rebakePayload } } 698 700 ); 701 + await mirrorPendingRebake(pieceName, rebakePayload); 699 702 const mintStatus = await checkMintStatus(pieceName, CONTRACT_ADDRESS); 700 703 await markJobReady(jobId, { 701 704 rebake: true, piece: pieceName, artifactUri, thumbnailUri,
+118 -99
system/netlify/functions/keep-update-confirm.mjs
··· 1 - // keep-update-confirm.mjs - Confirm a client-side metadata update and update MongoDB 2 - // 3 - // POST /api/keep-update-confirm - Record a successful client-side wallet metadata update 4 - // This is called after the user signs the edit_metadata transaction with their wallet 5 - // to update the kidlisp record with the new URIs. 6 - 1 + // keep-update-confirm.mjs - Confirm a client-side metadata update and update MongoDB 2 + // 3 + // POST /api/keep-update-confirm - Record a successful client-side wallet metadata update 4 + // This is called after the user signs the edit_metadata transaction with their wallet 5 + // to update the kidlisp record with the new URIs. 6 + 7 7 import { authorize, hasAdmin } from "../../backend/authorization.mjs"; 8 8 import { connect } from "../../backend/database.mjs"; 9 9 import { respond } from "../../backend/http.mjs"; 10 10 import { getKeepsContractAddress, LEGACY_KEEPS_CONTRACT } from "../../backend/tezos-keeps-contract.mjs"; 11 + import { mirrorRecordMint } from "../../backend/kidlisp-dual-write.mjs"; 11 12 12 13 // Configuration 13 14 const NETWORK = process.env.TEZOS_NETWORK || "mainnet"; ··· 116 117 117 118 return { contractProfile, contractVersion }; 118 119 } 119 - 120 - export async function handler(event, context) { 121 - if (event.httpMethod === "OPTIONS") { 122 - return respond(204, ""); 123 - } 124 - 125 - if (event.httpMethod !== "POST") { 126 - return respond(405, { error: "Method not allowed" }); 127 - } 128 - 129 - let database; 130 - try { 131 - database = await connect(); 132 - } catch (connectError) { 133 - console.error("❌ MongoDB connection failed:", connectError.message); 134 - return respond(503, { error: "Database temporarily unavailable" }); 135 - } 136 - 137 - try { 138 - // Verify user is authenticated 139 - const user = await authorize(event.headers); 140 - if (!user) { 141 - await database.disconnect(); 142 - return respond(401, { error: "Authentication required" }); 143 - } 144 - 145 - // Parse body 146 - const body = JSON.parse(event.body || "{}"); 147 - const { 148 - piece, 149 - tokenId, 150 - txHash, 120 + 121 + export async function handler(event, context) { 122 + if (event.httpMethod === "OPTIONS") { 123 + return respond(204, ""); 124 + } 125 + 126 + if (event.httpMethod !== "POST") { 127 + return respond(405, { error: "Method not allowed" }); 128 + } 129 + 130 + let database; 131 + try { 132 + database = await connect(); 133 + } catch (connectError) { 134 + console.error("❌ MongoDB connection failed:", connectError.message); 135 + return respond(503, { error: "Database temporarily unavailable" }); 136 + } 137 + 138 + try { 139 + // Verify user is authenticated 140 + const user = await authorize(event.headers); 141 + if (!user) { 142 + await database.disconnect(); 143 + return respond(401, { error: "Authentication required" }); 144 + } 145 + 146 + // Parse body 147 + const body = JSON.parse(event.body || "{}"); 148 + const { 149 + piece, 150 + tokenId, 151 + txHash, 151 152 artifactUri, 152 153 thumbnailUri, 153 154 metadataUri, ··· 155 156 contractProfile, 156 157 contractVersion, 157 158 } = body; 158 - 159 - if (!piece || !txHash) { 160 - await database.disconnect(); 161 - return respond(400, { error: "Missing piece or txHash" }); 162 - } 163 - 164 - // Clean piece name (remove $ prefix if present) 159 + 160 + if (!piece || !txHash) { 161 + await database.disconnect(); 162 + return respond(400, { error: "Missing piece or txHash" }); 163 + } 164 + 165 + // Clean piece name (remove $ prefix if present) 165 166 const cleanPiece = piece.replace(/^\$/, ""); 166 167 const defaultContract = await getKeepsContractAddress({ 167 168 db: database.db, ··· 177 178 requestedVersion: contractVersion, 178 179 }); 179 180 const normalizedTokenId = normalizeTokenId(tokenId); 180 - 181 - // Find the kidlisp record 182 - const collection = database.db.collection("kidlisp"); 183 - const record = await collection.findOne({ code: cleanPiece }); 184 - 185 - if (!record) { 186 - await database.disconnect(); 187 - return respond(404, { error: `Piece '$${cleanPiece}' not found` }); 188 - } 189 - 190 - // Verify ownership (user must own the piece or be admin) 191 - const isAdmin = await hasAdmin(user); 192 - if (!isAdmin && record.user && record.user !== user.sub) { 193 - console.warn(`❌ User ${user.sub} tried to confirm update for piece owned by ${record.user}`); 194 - await database.disconnect(); 195 - return respond(403, { error: "Not authorized to confirm this update" }); 196 - } 197 - 198 - // Update the record - move pending URIs to actual URIs and record the update 199 - const updateResult = await collection.updateOne( 200 - { code: cleanPiece }, 201 - { 202 - $set: { 203 - // Update contract-specific data 204 - [`tezos.contracts.${effectiveContract}.artifactUri`]: artifactUri, 205 - [`tezos.contracts.${effectiveContract}.thumbnailUri`]: thumbnailUri, 181 + 182 + // Find the kidlisp record 183 + const collection = database.db.collection("kidlisp"); 184 + const record = await collection.findOne({ code: cleanPiece }); 185 + 186 + if (!record) { 187 + await database.disconnect(); 188 + return respond(404, { error: `Piece '$${cleanPiece}' not found` }); 189 + } 190 + 191 + // Verify ownership (user must own the piece or be admin) 192 + const isAdmin = await hasAdmin(user); 193 + if (!isAdmin && record.user && record.user !== user.sub) { 194 + console.warn(`❌ User ${user.sub} tried to confirm update for piece owned by ${record.user}`); 195 + await database.disconnect(); 196 + return respond(403, { error: "Not authorized to confirm this update" }); 197 + } 198 + 199 + // Update the record - move pending URIs to actual URIs and record the update 200 + const updateResult = await collection.updateOne( 201 + { code: cleanPiece }, 202 + { 203 + $set: { 204 + // Update contract-specific data 205 + [`tezos.contracts.${effectiveContract}.artifactUri`]: artifactUri, 206 + [`tezos.contracts.${effectiveContract}.thumbnailUri`]: thumbnailUri, 206 207 [`tezos.contracts.${effectiveContract}.metadataUri`]: metadataUri, 207 208 [`tezos.contracts.${effectiveContract}.lastUpdatedAt`]: new Date(), 208 209 [`tezos.contracts.${effectiveContract}.lastUpdateTxHash`]: txHash, ··· 226 227 }, 227 228 $unset: { 228 229 // Clear pending state 229 - pendingRebake: "", 230 - [`tezos.contracts.${effectiveContract}.pendingMetadataUri`]: "", 231 - [`tezos.contracts.${effectiveContract}.pendingArtifactUri`]: "", 232 - [`tezos.contracts.${effectiveContract}.pendingThumbnailUri`]: "", 233 - } 234 - } 235 - ); 236 - 237 - if (updateResult.modifiedCount === 0 && updateResult.matchedCount === 0) { 238 - console.warn(`❌ Failed to update piece ${cleanPiece}`); 239 - await database.disconnect(); 240 - return respond(500, { error: "Failed to record update" }); 241 - } 242 - 243 - console.log(`✅ Confirmed metadata update for $${cleanPiece} (token #${tokenId || "?"}): ${txHash}`); 244 - 245 - await database.disconnect(); 246 - return respond(200, { 247 - success: true, 248 - piece: cleanPiece, 249 - tokenId, 230 + pendingRebake: "", 231 + [`tezos.contracts.${effectiveContract}.pendingMetadataUri`]: "", 232 + [`tezos.contracts.${effectiveContract}.pendingArtifactUri`]: "", 233 + [`tezos.contracts.${effectiveContract}.pendingThumbnailUri`]: "", 234 + } 235 + } 236 + ); 237 + 238 + if (updateResult.modifiedCount === 0 && updateResult.matchedCount === 0) { 239 + console.warn(`❌ Failed to update piece ${cleanPiece}`); 240 + await database.disconnect(); 241 + return respond(500, { error: "Failed to record update" }); 242 + } 243 + 244 + const resolvedTokenIdForMirror = normalizedTokenId ?? normalizeTokenId(record?.kept?.tokenId); 245 + if (resolvedTokenIdForMirror !== null) { 246 + await mirrorRecordMint(cleanPiece, { 247 + tokenId: resolvedTokenIdForMirror, 248 + contractAddress: effectiveContract, 249 + network: NETWORK, 250 + txHash: txHash || record?.kept?.txHash || null, 251 + contractProfile: contractIdentity.contractProfile || null, 252 + contractVersion: contractIdentity.contractVersion || null, 253 + artifactUri: artifactUri || null, 254 + thumbnailUri: thumbnailUri || null, 255 + metadataUri: metadataUri || null, 256 + keptAt: new Date(), 257 + keptBy: record?.kept?.keptBy || user.sub, 258 + walletAddress: record?.kept?.walletAddress || null, 259 + }, { source: "update" }); 260 + } 261 + 262 + console.log(`✅ Confirmed metadata update for $${cleanPiece} (token #${tokenId || "?"}): ${txHash}`); 263 + 264 + await database.disconnect(); 265 + return respond(200, { 266 + success: true, 267 + piece: cleanPiece, 268 + tokenId, 250 269 txHash, 251 270 artifactUri, 252 271 thumbnailUri, ··· 254 273 contractProfile: contractIdentity.contractProfile || null, 255 274 contractVersion: contractIdentity.contractVersion || null, 256 275 }); 257 - 258 - } catch (err) { 259 - console.error("❌ keep-update-confirm error:", err); 260 - if (database) await database.disconnect(); 261 - return respond(500, { error: err.message }); 262 - } 263 - } 276 + 277 + } catch (err) { 278 + console.error("❌ keep-update-confirm error:", err); 279 + if (database) await database.disconnect(); 280 + return respond(500, { error: err.message }); 281 + } 282 + }
+18 -2
system/netlify/functions/keep-update.mjs
··· 9 9 import { authorize, hasAdmin } from "../../backend/authorization.mjs"; 10 10 import { connect } from "../../backend/database.mjs"; 11 11 import { getKeepsContractAddress, LEGACY_KEEPS_CONTRACT } from "../../backend/tezos-keeps-contract.mjs"; 12 + import { mirrorRecordMint } from "../../backend/kidlisp-dual-write.mjs"; 12 13 import { stream } from "@netlify/functions"; 13 14 import { TezosToolkit, MichelsonMap } from "@taquito/taquito"; 14 15 import { InMemorySigner } from "@taquito/signer"; ··· 461 462 // Use contract-keyed storage: tezos.contracts[CONTRACT_ADDRESS] 462 463 await collection.updateOne( 463 464 { code: pieceName }, 464 - { 465 - $set: { 465 + { 466 + $set: { 466 467 [`tezos.contracts.${CONTRACT_ADDRESS}.artifactUri`]: artifactUri, 467 468 [`tezos.contracts.${CONTRACT_ADDRESS}.thumbnailUri`]: thumbnailUri, 468 469 [`tezos.contracts.${CONTRACT_ADDRESS}.metadataUri`]: newMetadataUri, ··· 471 472 }, 472 473 $unset: { pendingRebake: "" } 473 474 } 475 + ); 476 + 477 + await mirrorRecordMint( 478 + pieceName, 479 + { 480 + tokenId: parseInt(tokenId, 10), 481 + contractAddress: CONTRACT_ADDRESS, 482 + network: NETWORK, 483 + txHash: op.hash, 484 + artifactUri, 485 + thumbnailUri, 486 + metadataUri: newMetadataUri, 487 + keptAt: new Date(), 488 + }, 489 + { source: "update_server" }, 474 490 ); 475 491 476 492 await send("progress", { stage: "database", message: "✓ Database updated" });
+10 -6
system/netlify/functions/store-kidlisp-datomic.mjs
··· 42 42 43 43 function selectPrimaryKeep(keeps, preferredContract) { 44 44 if (!Array.isArray(keeps) || keeps.length === 0) return null; 45 - if (!preferredContract) return keeps[0]; 46 - const pref = keeps.find( 47 - (k) => 48 - (k.contractAddress || "").toLowerCase() === 49 - preferredContract.toLowerCase() 45 + const byRecency = [...keeps].sort((a, b) => { 46 + const at = a?.keptAt ? new Date(a.keptAt).getTime() : 0; 47 + const bt = b?.keptAt ? new Date(b.keptAt).getTime() : 0; 48 + return bt - at; 49 + }); 50 + if (!preferredContract) return byRecency[0]; 51 + const want = preferredContract.toLowerCase(); 52 + const pref = byRecency.find( 53 + (k) => (k.contractAddress || "").toLowerCase() === want 50 54 ); 51 - return pref || keeps[0]; 55 + return pref || byRecency[0]; 52 56 } 53 57 54 58 function filterKeeps(keeps, { contract, contractProfile, contractVersion }) {