Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hold-release-expected-sha.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@buildinternet/uploads": patch
---

`uploads approve` sends the content hash from the held list. The approve fails when those bytes changed after the list.
126 changes: 126 additions & 0 deletions apps/api/migrations/20261028140000_blob_hold.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
-- Current hold decision for a set of bytes, keyed by workspace and content
-- hash so a release follows the bytes across a rename, a promote, or an
-- attach. `file_events` stays the append-only history. This row is the
-- lookup gates and the held list use.
--
-- Backfill: an `ai.hold=approved` row with a `file_content_hash` becomes a
-- release event and a `released` projection, then the row is reset to
-- `pending`. A hash that still has a pending file in the held bucket is
-- left held, so those bytes are not stranded. A pending row with a hash
-- becomes `held`. A row with no hash is skipped and left as it is. After
-- the migration, the skip count is
-- the number of `ai.hold=approved` rows still stored:
-- SELECT COUNT(*) FROM file_metadata
-- WHERE meta_key = 'ai.hold' AND meta_value = 'approved';
CREATE TABLE blob_hold (
workspace TEXT NOT NULL,
content_sha256 TEXT NOT NULL,
state TEXT NOT NULL,
actor TEXT,
at TEXT,
PRIMARY KEY (workspace, content_sha256)
);

CREATE INDEX blob_hold_state_idx ON blob_hold (state, workspace);

INSERT INTO blob_hold (workspace, content_sha256, state, actor, at)
SELECT h.workspace, h.content_sha256, 'held', NULL, h.updated_at
FROM file_metadata m
JOIN file_content_hash h
ON h.workspace = m.workspace AND h.object_key = m.object_key
WHERE m.meta_key = 'ai.hold' AND m.meta_value = 'pending'
ON CONFLICT(workspace, content_sha256) DO NOTHING;

INSERT INTO file_events (
workspace, object_key, content_sha256, kind, action, actor, created_at
)
SELECT m.workspace, m.object_key, h.content_sha256, 'override', 'release',
by_row.meta_value,
COALESCE(at_row.meta_value, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
FROM file_metadata m
JOIN file_content_hash h
ON h.workspace = m.workspace AND h.object_key = m.object_key
LEFT JOIN file_metadata by_row
ON by_row.workspace = m.workspace AND by_row.object_key = m.object_key
AND by_row.meta_key = 'ai.hold.by'
LEFT JOIN file_metadata at_row
ON at_row.workspace = m.workspace AND at_row.object_key = m.object_key
AND at_row.meta_key = 'ai.hold.at'
WHERE m.meta_key = 'ai.hold' AND m.meta_value = 'approved'
AND NOT EXISTS (
SELECT 1
FROM file_content_hash other
JOIN file_metadata pending
ON pending.workspace = other.workspace
AND pending.object_key = other.object_key
AND pending.meta_key = 'ai.hold'
AND pending.meta_value = 'pending'
JOIN file_metadata moved
ON moved.workspace = other.workspace
AND moved.object_key = other.object_key
AND moved.meta_key = 'ai.hold.moved'
AND moved.meta_value = '1'
WHERE other.workspace = h.workspace
AND other.content_sha256 = h.content_sha256
);

INSERT INTO blob_hold (workspace, content_sha256, state, actor, at)
SELECT h.workspace, h.content_sha256, 'released',
MAX(by_row.meta_value),
COALESCE(MAX(at_row.meta_value), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
FROM file_metadata m
JOIN file_content_hash h
ON h.workspace = m.workspace AND h.object_key = m.object_key
LEFT JOIN file_metadata by_row
ON by_row.workspace = m.workspace AND by_row.object_key = m.object_key
AND by_row.meta_key = 'ai.hold.by'
LEFT JOIN file_metadata at_row
ON at_row.workspace = m.workspace AND at_row.object_key = m.object_key
AND at_row.meta_key = 'ai.hold.at'
WHERE m.meta_key = 'ai.hold' AND m.meta_value = 'approved'
AND NOT EXISTS (
SELECT 1
FROM file_content_hash other
JOIN file_metadata pending
ON pending.workspace = other.workspace
AND pending.object_key = other.object_key
AND pending.meta_key = 'ai.hold'
AND pending.meta_value = 'pending'
JOIN file_metadata moved
ON moved.workspace = other.workspace
AND moved.object_key = other.object_key
AND moved.meta_key = 'ai.hold.moved'
AND moved.meta_value = '1'
WHERE other.workspace = h.workspace
AND other.content_sha256 = h.content_sha256
)
GROUP BY h.workspace, h.content_sha256
ON CONFLICT(workspace, content_sha256) DO UPDATE SET
state = excluded.state,
actor = excluded.actor,
at = excluded.at;

UPDATE file_metadata
SET meta_value = 'pending'
WHERE meta_key = 'ai.hold' AND meta_value = 'approved'
AND EXISTS (
SELECT 1 FROM file_content_hash h
WHERE h.workspace = file_metadata.workspace
AND h.object_key = file_metadata.object_key
AND NOT EXISTS (
SELECT 1
FROM file_content_hash other
JOIN file_metadata pending
ON pending.workspace = other.workspace
AND pending.object_key = other.object_key
AND pending.meta_key = 'ai.hold'
AND pending.meta_value = 'pending'
JOIN file_metadata moved
ON moved.workspace = other.workspace
AND moved.object_key = other.object_key
AND moved.meta_key = 'ai.hold.moved'
AND moved.meta_value = '1'
WHERE other.workspace = h.workspace
AND other.content_sha256 = h.content_sha256
)
);
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"./storage": "./src/storage.ts",
"./files": "./src/files-core.ts",
"./file-metadata": "./src/file-metadata.ts",
"./content-hash": "./src/content-hash.ts",
"./file-search": "./src/file-search.ts",
"./file-type-sql": "./src/file-type-sql.ts",
"./github-pr-activity": "./src/github-pr-activity.ts",
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1496,11 +1496,15 @@ async function finishReused(
meta: Record<string, string>,
hold: boolean,
enforceHold: boolean,
contentSha256: string | undefined,
): Promise<Record<string, string>> {
const { applyUploadHold, resyncCommentAfterClassification } =
await import("./upload-hold-service");
const placed = hold
? await applyUploadHold(env, ws, workspaceName, key, meta, { enforce: enforceHold })
? await applyUploadHold(env, ws, workspaceName, key, meta, {
enforce: enforceHold,
contentSha256,
})
: false;
// A hold re-syncs the comment itself.
if (!placed) await resyncCommentAfterClassification(env, ws, workspaceName, key);
Expand Down Expand Up @@ -1577,6 +1581,7 @@ export async function classifyAndStore(
const trivial = trivialImageSkip(contentType, bytes);
if (trivial) return await storeSkip(env, workspaceName, key, trivial, provider, bytes);

const contentSha256 = await contentSha256Hex(bytes).catch(() => undefined);
const reused = await tryReuseClassification(env, workspaceName, key, bytes, contentType);
if (reused.meta) {
return finishReused(
Expand All @@ -1587,6 +1592,7 @@ export async function classifyAndStore(
reused.meta,
reused.hold,
deps?.enforceHold === true,
contentSha256,
);
}

Expand Down Expand Up @@ -1638,6 +1644,7 @@ export async function classifyAndStore(
meta,
false,
deps?.enforceHold === true,
contentSha256,
);
}
} catch (err) {
Expand Down Expand Up @@ -1689,6 +1696,7 @@ export async function classifyAndStore(
await import("./upload-hold-service");
const placed = await applyUploadHold(env, ws, workspaceName, key, stored, {
enforce: deps?.enforceHold === true,
contentSha256,
});
// A hold re-syncs the comment itself; otherwise the AI notes need the new labels.
if (!placed) await resyncCommentAfterClassification(env, ws, workspaceName, key);
Expand Down
53 changes: 53 additions & 0 deletions apps/api/src/content-hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,66 @@ export async function contentHashForKey(
}
}

/** Current hashes for `keys`, omitting keys with no row. Empty when the read fails. */
export async function contentHashesForKeys(
db: D1Queryable,
workspace: string,
keys: string[],
): Promise<Map<string, string>> {
const found = new Map<string, string>();
if (keys.length === 0) return found;
try {
const chunkSize = 80;
for (let i = 0; i < keys.length; i += chunkSize) {
const chunk = keys.slice(i, i + chunkSize);
const placeholders = chunk.map(() => "?").join(", ");
const result = await db
.prepare(
`SELECT object_key, content_sha256 FROM file_content_hash
WHERE workspace = ? AND object_key IN (${placeholders})`,
)
.bind(workspace, ...chunk)
.all<{ object_key: string; content_sha256: string }>();
for (const row of result.results ?? []) found.set(row.object_key, row.content_sha256);
}
} catch {
return found;
}
return found;
}

/**
* Record this object's stored-body hash, replacing any prior hash for the same
* key (an overwrite changes the bytes a key points at).
*
* Never throws: the object is already durably stored by the time this runs, and
* a missing index row costs a future inheritance, not correctness.
*/
export async function deleteContentHashes(
db: D1Queryable,
workspace: string,
keys: string[],
): Promise<void> {
if (keys.length === 0) return;
const chunkSize = 80;
for (let i = 0; i < keys.length; i += chunkSize) {
const chunk = keys.slice(i, i + chunkSize);
const placeholders = chunk.map(() => "?").join(", ");
try {
await db
.prepare(
`DELETE FROM file_content_hash WHERE workspace = ? AND object_key IN (${placeholders})`,
)
.bind(workspace, ...chunk)
.run();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("no such table")) return;
throw err;
}
}
}

export async function recordContentHash(
db: D1Queryable,
workspace: string,
Expand Down
Loading
Loading