mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
Credit the oldest published match for a shared hash, and pick up the parser fix
Three things, all downstream of the A1111 "Lora hashes" regression. Take @civitai/generation-metadata 0.3.0. A `Lora hashes` block whose first entry carried a bracket or non-Latin character was iterated character by character, so A1111 and Forge uploads have attached no LoRAs since 2026-08-31. 0.3.0 decides nested hash blocks by key rather than by the shape of the creator's filename. This is a minor bump, so the caret on ^0.2.0 would not have crossed it on its own. Add /api/testing/backfill-lora-hashes for the 13,596 images already affected. They repair without their original files: the `lora:0`, `lora:1` ... keys ARE the block, in order, so the endpoint reassembles it, re-parses through parsePromptMetadata (the shipped parser, deliberately -- the backfill and the upload path must not be able to disagree about what a block means), rewrites meta.hashes/meta.resources and re-runs detection. Dry run unless apply=true. Paging is keyset on (createdAt, id) to match the index the filter walks; ordering by id alone re-scans the window every batch, measured at 403k rows filtered to find the first 100. Measured on 300 sampled images: 86% of what it would write is genuinely absent today, and 298 of 300 gain at least one resource. Fix resolveImageMeta's shared-hash tie-break, which read `>` where get_image_resources.sql reads ascending. One hash can sit on files owned by several people -- in practice a re-upload of someone else's weights -- so the ordering decides who gets credited. The SQL took the earliest published copy, the closest proxy for the original uploader; the generator took the most recent, which is the re-uploader by definition. On nine such hashes found in a 300-image sample, the two implementations disagreed on every one: the image page credited @Zavy, @GZees, @Jedas and friends while the generator would have credited whoever posted the copy later. The comparison was inline and needed a database to exercise, which is how it drifted. Extracted as prefersHashMatch and pinned from both sides: a unit test for the direction, the precedence and the strictness, and an assertion in the existing source gate that the SQL's ORDER BY stays ascending. Both verified by reverting them -- flipping the TypeScript fails 3 tests, flipping the SQL fails the gate. Nothing compared the two implementations before; the gate's own header says it can assert the filters exist in both but not that they agree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -137,7 +137,7 @@
|
||||
"@civitai/db-queries": "workspace:*",
|
||||
"@civitai/db-schema": "workspace:*",
|
||||
"@civitai/flipt": "workspace:*",
|
||||
"@civitai/generation-metadata": "^0.2.0",
|
||||
"@civitai/generation-metadata": "^0.3.0",
|
||||
"@civitai/moderation": "workspace:*",
|
||||
"@civitai/next-axiom": "^0.17.0",
|
||||
"@civitai/orchestration-client": "0.2.0-beta.104",
|
||||
|
||||
Generated
+5
-5
@@ -62,8 +62,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:packages/civitai-flipt
|
||||
'@civitai/generation-metadata':
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.0
|
||||
'@civitai/moderation':
|
||||
specifier: workspace:*
|
||||
version: link:packages/civitai-moderation
|
||||
@@ -2215,8 +2215,8 @@ packages:
|
||||
resolution: {integrity: sha512-j+aIZy+d3mvPhE93lrx9d3k08PABFeH025DIYuR1T5LWqJ9xnAHTuFXdhLCeIDZESdOg95fNJcVqhOvAcCpddw==}
|
||||
engines: {node: '>=22.18.0', npm: '>=10', pnpm: '>=10'}
|
||||
|
||||
'@civitai/generation-metadata@0.2.0':
|
||||
resolution: {integrity: sha512-QAevrDK/o4wU33zCsrGbF2zED01Pyg7z/E5nnBAV5nvEInlw6mzYqWZ3mU6rbz87ej4epe8Knj6WZj7USjvhDg==}
|
||||
'@civitai/generation-metadata@0.3.0':
|
||||
resolution: {integrity: sha512-+mnPE4cz+1qrIquvybz97mylvxXsBv/w1p3lR5lsLQwL5FgJHb0EkIl33fo8mEy0IzyJadol6fd2NmLayzIY+A==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@civitai/next-axiom@0.17.0':
|
||||
@@ -13440,7 +13440,7 @@ snapshots:
|
||||
dependencies:
|
||||
fast-xml-parser: 5.10.1
|
||||
|
||||
'@civitai/generation-metadata@0.2.0':
|
||||
'@civitai/generation-metadata@0.3.0':
|
||||
dependencies:
|
||||
exifreader: 4.41.3
|
||||
zod: 4.4.3
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Backfill for the A1111 "Lora hashes" regression (ClickUp 868m53cpr).
|
||||
*
|
||||
* Between 2026-08-31 and the @civitai/generation-metadata 0.3.0 upgrade, a `Lora
|
||||
* hashes` block whose FIRST entry carried an awkward character (brackets, non-Latin
|
||||
* script) failed the parser's shape test, unquoted to a plain string, and was then
|
||||
* iterated character by character — so `meta.hashes` gained one `lora:<n>` key per
|
||||
* CHARACTER and the whole block's LoRAs went undetected.
|
||||
*
|
||||
* Those rows repair in place: the `lora:0`, `lora:1` … values ARE the original block,
|
||||
* in order. This reassembles it, re-parses it with the shipped (fixed) parser, rewrites
|
||||
* `meta.hashes` / `meta.resources`, and re-runs detection.
|
||||
*
|
||||
* GET /api/testing/backfill-lora-hashes?token=$WEBHOOK_TOKEN
|
||||
* days=14 how far back to scan (createdAt >= now() - days). Default 14.
|
||||
* batchSize=100 images per batch, max 500.
|
||||
* maxBatches=0 0 = keep going until the window is exhausted or maxMs runs out.
|
||||
* maxMs=50000 stop cleanly before the platform's request timeout and hand back a
|
||||
* cursor. Resume with after=<nextCursor> from the response.
|
||||
* after= resume point, `<iso createdAt>|<id>` from a previous response.
|
||||
* apply=false DRY RUN unless apply=true. Nothing is written without it.
|
||||
* userId=<id> optional: restrict to one uploader.
|
||||
* verbose=false include a per-image sample of what would change.
|
||||
*
|
||||
* Reads the fixed parser through `parsePromptMetadata`, deliberately: the backfill and
|
||||
* the upload path must not be able to disagree about what a block means. That also means
|
||||
* this endpoint is only correct once the app is on a build with the fix.
|
||||
*
|
||||
* Paging is keyset on (createdAt, id) because that is the index the filter walks
|
||||
* (`Image_createdAt_id`). Ordering by id alone makes every batch re-scan the window from
|
||||
* the start — measured at 403k rows filtered to find the first 100, and quadratic from
|
||||
* there. There is no index that can serve the `meta` LIKE, so the scan is the cost; the
|
||||
* point of the keyset is that it is paid once, not once per batch.
|
||||
*/
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { dbRead, dbWrite } from '~/server/db/client';
|
||||
import { refreshImageResources } from '~/server/services/image.service';
|
||||
import { parsePromptMetadata } from '~/utils/metadata';
|
||||
import { WebhookEndpoint } from '~/server/utils/endpoint-helpers';
|
||||
|
||||
const CHAR_SPLIT_KEY = /^lora:\d+$/;
|
||||
const MAX_BATCH = 500;
|
||||
|
||||
type ImageRow = { id: number; createdAt: Date; meta: Record<string, any> | null };
|
||||
|
||||
/** The `lora:<n>` values are the original block's characters, in index order. */
|
||||
function reassembleBlock(hashes: Record<string, unknown>): string {
|
||||
const chars: string[] = [];
|
||||
for (const [key, value] of Object.entries(hashes)) {
|
||||
const match = /^lora:(\d+)$/.exec(key);
|
||||
if (match && typeof value === 'string') chars[Number(match[1])] = value;
|
||||
}
|
||||
// A hole means the row is not what we think it is; refuse rather than guess.
|
||||
return chars.length && [...chars].every((c) => typeof c === 'string') ? chars.join('') : '';
|
||||
}
|
||||
|
||||
function recoverLoraHashes(block: string): Record<string, string> | null {
|
||||
// The block sits inside a quoted A1111 value, so an embedded quote is not something
|
||||
// this format can represent — treat it as unrecognized input rather than repair it.
|
||||
if (!block || block.includes('"')) return null;
|
||||
const meta = parsePromptMetadata(`backfill\nSteps: 1, Lora hashes: "${block}"`);
|
||||
const hashes = (meta?.hashes ?? {}) as Record<string, string>;
|
||||
const recovered: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(hashes)) {
|
||||
if (key.startsWith('lora:') && !CHAR_SPLIT_KEY.test(key)) recovered[key] = value;
|
||||
}
|
||||
return Object.keys(recovered).length ? recovered : null;
|
||||
}
|
||||
|
||||
/** `{type:'lora', name:'17', hash:'e'}` — one bogus resource per character. */
|
||||
function isCharSplitResource(resource: unknown): boolean {
|
||||
const r = resource as { name?: unknown; hash?: unknown };
|
||||
return (
|
||||
typeof r?.name === 'string' &&
|
||||
/^\d+$/.test(r.name) &&
|
||||
typeof r?.hash === 'string' &&
|
||||
r.hash.length === 1
|
||||
);
|
||||
}
|
||||
|
||||
function parseAfter(value: unknown): { createdAt: Date; id: number } | null {
|
||||
if (typeof value !== 'string' || !value.includes('|')) return null;
|
||||
const [iso, id] = value.split('|');
|
||||
const createdAt = new Date(iso);
|
||||
if (Number.isNaN(createdAt.getTime()) || !Number(id)) return null;
|
||||
return { createdAt, id: Number(id) };
|
||||
}
|
||||
|
||||
export default WebhookEndpoint(async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const days = Math.min(Number(req.query.days ?? 14) || 14, 120);
|
||||
const batchSize = Math.min(Number(req.query.batchSize ?? 100) || 100, MAX_BATCH);
|
||||
const maxBatches = Number(req.query.maxBatches ?? 0) || 0;
|
||||
const maxMs = Math.min(Number(req.query.maxMs ?? 50_000) || 50_000, 280_000);
|
||||
const apply = req.query.apply === 'true';
|
||||
const userId = req.query.userId ? Number(req.query.userId) : undefined;
|
||||
const verbose = req.query.verbose === 'true';
|
||||
|
||||
const startedAt = Date.now();
|
||||
// Computed here, not as make_interval(days => ${days}): Prisma binds a JS number as
|
||||
// int8 and make_interval takes int4, so the parameterised form fails at runtime while
|
||||
// the same SQL with a literal works. A Date binds as timestamptz and matches the column.
|
||||
const since = new Date(startedAt - days * 24 * 60 * 60 * 1000);
|
||||
const samples: unknown[] = [];
|
||||
let after = parseAfter(req.query.after);
|
||||
let batches = 0;
|
||||
let scanned = 0;
|
||||
let repaired = 0;
|
||||
let loraHashesRecovered = 0;
|
||||
let skippedUnrecoverable = 0;
|
||||
let exhausted = false;
|
||||
let stoppedOn: 'exhausted' | 'maxBatches' | 'maxMs' = 'exhausted';
|
||||
const failures: { id: number; error: string }[] = [];
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
if (maxBatches && batches >= maxBatches) {
|
||||
stoppedOn = 'maxBatches';
|
||||
break;
|
||||
}
|
||||
if (Date.now() - startedAt > maxMs) {
|
||||
stoppedOn = 'maxMs';
|
||||
break;
|
||||
}
|
||||
|
||||
const rows = await dbRead.$queryRaw<ImageRow[]>`
|
||||
SELECT i.id, i."createdAt", i.meta
|
||||
FROM "Image" i
|
||||
WHERE i."createdAt" >= ${since}
|
||||
${
|
||||
after
|
||||
? Prisma.sql`AND (i."createdAt", i.id) > (${after.createdAt}::timestamptz, ${after.id}::int)`
|
||||
: Prisma.empty
|
||||
}
|
||||
AND i.meta->>'hashes' LIKE '%"lora:0":%'
|
||||
${userId ? Prisma.sql`AND i."userId" = ${userId}` : Prisma.empty}
|
||||
ORDER BY i."createdAt", i.id
|
||||
LIMIT ${batchSize}
|
||||
`;
|
||||
if (!rows.length) {
|
||||
exhausted = true;
|
||||
break;
|
||||
}
|
||||
|
||||
batches++;
|
||||
const last = rows[rows.length - 1];
|
||||
after = { createdAt: last.createdAt, id: last.id };
|
||||
|
||||
for (const row of rows) {
|
||||
scanned++;
|
||||
const meta = (row.meta ?? {}) as Record<string, any>;
|
||||
const hashes = (meta.hashes ?? {}) as Record<string, string>;
|
||||
if (!Object.keys(hashes).some((k) => CHAR_SPLIT_KEY.test(k))) continue;
|
||||
|
||||
const recovered = recoverLoraHashes(reassembleBlock(hashes));
|
||||
if (!recovered) {
|
||||
skippedUnrecoverable++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const cleanedHashes: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(hashes)) {
|
||||
if (!CHAR_SPLIT_KEY.test(key)) cleanedHashes[key] = value;
|
||||
}
|
||||
const nextHashes = { ...cleanedHashes, ...recovered };
|
||||
const nextResources = Array.isArray(meta.resources)
|
||||
? meta.resources.filter((r: unknown) => !isCharSplitResource(r))
|
||||
: meta.resources;
|
||||
|
||||
if (verbose && samples.length < 10) {
|
||||
samples.push({
|
||||
id: row.id,
|
||||
charKeysRemoved: Object.keys(hashes).length - Object.keys(cleanedHashes).length,
|
||||
recovered: Object.keys(recovered),
|
||||
});
|
||||
}
|
||||
|
||||
repaired++;
|
||||
loraHashesRecovered += Object.keys(recovered).length;
|
||||
if (!apply) continue;
|
||||
|
||||
try {
|
||||
await dbWrite.image.update({
|
||||
where: { id: row.id },
|
||||
data: { meta: { ...meta, hashes: nextHashes, resources: nextResources } },
|
||||
});
|
||||
// Drops the stale detected rows (including the ones the garbage produced) and
|
||||
// re-derives them from the repaired meta.
|
||||
await refreshImageResources(row.id);
|
||||
} catch (e) {
|
||||
failures.push({ id: row.id, error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
mode: apply ? 'APPLIED' : 'DRY RUN (pass apply=true to write)',
|
||||
window: `${days} days`,
|
||||
userId: userId ?? 'all',
|
||||
stoppedOn,
|
||||
exhausted,
|
||||
nextCursor: exhausted || !after ? null : `${after.createdAt.toISOString()}|${after.id}`,
|
||||
batches,
|
||||
batchSize,
|
||||
scanned,
|
||||
repaired,
|
||||
loraHashesRecovered,
|
||||
skippedUnrecoverable,
|
||||
failureCount: failures.length,
|
||||
failures: failures.slice(0, 20),
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
...(verbose ? { samples } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({
|
||||
error: (error as Error).message,
|
||||
progress: {
|
||||
batches,
|
||||
scanned,
|
||||
repaired,
|
||||
resumeFrom: after ? `${after.createdAt.toISOString()}|${after.id}` : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -49,6 +49,20 @@ describe('get_image_resources.sql', () => {
|
||||
expect(sql, 'never filters on mf.type').toMatch(/mf\.type\s+NOT\s+IN/i);
|
||||
});
|
||||
|
||||
it('breaks a shared-hash tie toward the earliest published version', () => {
|
||||
// A hash on files owned by several people is in practice a re-upload, so the ordering
|
||||
// decides whether the original creator or the re-uploader gets the image. ASCENDING
|
||||
// version_date is what picks the original. The TypeScript mirror read this backwards
|
||||
// until 2026-09-15 — same rule, two languages, opposite winners — so pin the direction
|
||||
// here and the mirror's in generation/__tests__/prefers-hash-match.
|
||||
const ordering = sql.match(/ORDER BY IIF\(irh\.detected[^)]*\)[^\n]*/)?.[0] ?? '';
|
||||
expect(ordering, 'no shared-hash tie-break ordering found').toContain('version_published');
|
||||
expect(ordering, 'version_date is no longer ascending, so the LATEST upload now wins').toMatch(
|
||||
/version_date,\s*file_id/
|
||||
);
|
||||
expect(ordering, 'version_date was made descending').not.toMatch(/version_date\s+DESC/i);
|
||||
});
|
||||
|
||||
it('survives the db:program splitter', () => {
|
||||
// scripts/prisma-prepare-programmability.mjs splits every file on the literal `---` and runs
|
||||
// each part as its own statement, so one in a comment cuts the function in half at deploy time.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { prefersHashMatch } from '~/server/services/generation/generation.service';
|
||||
|
||||
/**
|
||||
* One hash can sit on files owned by several people — in practice because someone
|
||||
* re-uploaded another creator's weights. `prefersHashMatch` decides who gets credited,
|
||||
* and it has to agree with get_image_resources.sql's
|
||||
* `ORDER BY IIF(version_published,0,1), version_date, file_id`, because that function
|
||||
* credits the image page while this credits the generator. They disagreed until
|
||||
* 2026-09-15: the SQL took the oldest, this took the newest, so the same file credited
|
||||
* the original creator in one place and the re-uploader in the other. Nothing compares
|
||||
* the two implementations, so the direction is pinned here.
|
||||
*/
|
||||
|
||||
const match = (over: Partial<Parameters<typeof prefersHashMatch>[0]> = {}) => ({
|
||||
versionPublished: true,
|
||||
versionDate: new Date('2025-01-01'),
|
||||
fileId: 100,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('prefersHashMatch', () => {
|
||||
it('takes any candidate when there is nothing to compare against', () => {
|
||||
expect(prefersHashMatch(match(), undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers a published version over an unpublished one, whatever the dates say', () => {
|
||||
const published = match({ versionPublished: true, versionDate: new Date('2026-01-01') });
|
||||
const unpublished = match({ versionPublished: false, versionDate: new Date('2020-01-01') });
|
||||
expect(prefersHashMatch(published, unpublished)).toBe(true);
|
||||
expect(prefersHashMatch(unpublished, published)).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers the OLDEST of two published versions, not the newest', () => {
|
||||
// The whole point: the later upload is the re-upload.
|
||||
const original = match({ versionDate: new Date('2024-02-06') });
|
||||
const reupload = match({ versionDate: new Date('2025-06-22') });
|
||||
expect(prefersHashMatch(original, reupload)).toBe(true);
|
||||
expect(prefersHashMatch(reupload, original)).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers the oldest among unpublished versions too', () => {
|
||||
const older = match({ versionPublished: false, versionDate: new Date('2024-01-01') });
|
||||
const newer = match({ versionPublished: false, versionDate: new Date('2026-01-01') });
|
||||
expect(prefersHashMatch(older, newer)).toBe(true);
|
||||
expect(prefersHashMatch(newer, older)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the lowest file id when published and date are identical', () => {
|
||||
const date = new Date('2025-01-01');
|
||||
expect(
|
||||
prefersHashMatch(
|
||||
match({ fileId: 5, versionDate: date }),
|
||||
match({ fileId: 9, versionDate: date })
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
prefersHashMatch(
|
||||
match({ fileId: 9, versionDate: date }),
|
||||
match({ fileId: 5, versionDate: date })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is a strict preference: an identical candidate does not displace the incumbent', () => {
|
||||
// Otherwise the last row the query happens to return wins, and the winner depends
|
||||
// on scan order rather than on the rule.
|
||||
expect(prefersHashMatch(match(), match())).toBe(false);
|
||||
});
|
||||
|
||||
it('reproduces the production duplicates it was written for', () => {
|
||||
// Real shared hashes: the earliest published copy is the original creator in each.
|
||||
const cases = [
|
||||
{ original: new Date('2024-02-06'), reupload: new Date('2025-06-22') }, // 1ac0c6cd4e92
|
||||
{ original: new Date('2025-05-13'), reupload: new Date('2025-06-30') }, // 61d7ee6c08bd
|
||||
{ original: new Date('2025-02-25'), reupload: new Date('2026-05-21') }, // 70a66c1a0734
|
||||
{ original: new Date('2026-07-01'), reupload: new Date('2026-08-02') }, // d85cf97b20d8
|
||||
];
|
||||
for (const { original, reupload } of cases) {
|
||||
expect(
|
||||
prefersHashMatch(match({ versionDate: original }), match({ versionDate: reupload })),
|
||||
`expected the ${original.toISOString().slice(0, 10)} upload to win`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1659,6 +1659,29 @@ export function extractHashCandidates(
|
||||
*
|
||||
* Returns { resources, params } where params are ready for the generation graph.
|
||||
*/
|
||||
type HashMatch = { versionPublished: boolean; versionDate: Date; fileId: number };
|
||||
|
||||
/**
|
||||
* Which of several files sharing one hash gets the credit. Mirrors
|
||||
* get_image_resources.sql's `ORDER BY IIF(version_published,0,1), version_date, file_id`:
|
||||
* published first, then OLDEST, then lowest file id.
|
||||
*
|
||||
* Oldest, not newest. A hash shared across owners is in practice a re-upload of someone
|
||||
* else's weights, so the earliest published copy is the closest thing to the original
|
||||
* uploader; preferring the most recent hands every duplicated model to whoever posted it
|
||||
* last. This read `>` until 2026-09-15, which meant the image page credited the original
|
||||
* and the generator credited the re-uploader for the same file — the two are the same
|
||||
* rule in two languages, and nothing compares them.
|
||||
*/
|
||||
export function prefersHashMatch(candidate: HashMatch, existing: HashMatch | undefined): boolean {
|
||||
if (!existing) return true;
|
||||
if (existing.versionPublished !== candidate.versionPublished) return candidate.versionPublished;
|
||||
const existingDate = existing.versionDate.valueOf();
|
||||
const candidateDate = candidate.versionDate.valueOf();
|
||||
if (existingDate !== candidateDate) return candidateDate < existingDate;
|
||||
return candidate.fileId < existing.fileId;
|
||||
}
|
||||
|
||||
export async function resolveImageMeta({
|
||||
input,
|
||||
user,
|
||||
@@ -1706,22 +1729,10 @@ export async function resolveImageMeta({
|
||||
`;
|
||||
|
||||
// Build a map of hash → best matching modelVersionId
|
||||
// When multiple files match the same hash, prefer published > recent > lowest fileId
|
||||
const bestByHash = new Map<string, (typeof hashResults)[0]>();
|
||||
for (const row of hashResults) {
|
||||
if (row.excludeFromAutoDetection) continue;
|
||||
const existing = bestByHash.get(row.hash);
|
||||
if (
|
||||
!existing ||
|
||||
(!existing.versionPublished && row.versionPublished) ||
|
||||
(existing.versionPublished === row.versionPublished &&
|
||||
row.versionDate > existing.versionDate) ||
|
||||
(existing.versionPublished === row.versionPublished &&
|
||||
existing.versionDate === row.versionDate &&
|
||||
row.fileId < existing.fileId)
|
||||
) {
|
||||
bestByHash.set(row.hash, row);
|
||||
}
|
||||
if (prefersHashMatch(row, bestByHash.get(row.hash))) bestByHash.set(row.hash, row);
|
||||
}
|
||||
|
||||
// Match hash candidates to resolved version IDs
|
||||
|
||||
Reference in New Issue
Block a user