Files
Oxicloud/migrations/20261020000000_file_attached_blobs.sql
T
Edouard Vanbelle fac82fea23 feat(storage): add storage.file_attached_blobs, the file-keyed half
Step 9 of docs/plan/derived-blobs.md. content_derived_blobs holds bytes
that are a pure function of a file's content, so they are keyed by that
content and shared by every file holding it. This table holds the
opposite: bytes a user supplied or chose, which must never be shared
across files. The key is what enforces it.

That difference is a security boundary, not a modelling preference. A
content-keyed client preview would let user A upload a file plus a
preview that misrepresents it; when user B later uploads the same bytes,
dedup matches and B is served A's preview. Content-keying is only safe
when the server can derive the bytes — there is nothing to poison,
because the same input yields the same output for everyone.

Required now rather than deferred: the SPA already generates and PUTs
previews for PDFs, and there is no server-side regeneration path for
them, so the sidecar migration has nowhere else to put those bytes.

uploaded_by is NOT NULL with no foreign key, per the provenance
convention rather than the plan's sketch. A FK with ON DELETE SET NULL
discards the audit trail exactly when it matters, and without an
ON DELETE clause it would block deleting a user outright. Deleting the
uploader must not rewrite history.

FileAttachedReferenceSource is registered in built_in_registry before
anything writes to the table, so dedup_gc's reap predicate already knows
it exists — otherwise the first sweep after the first attachment would
delete it. Manifest level only, like the derived source: these blobs are
almost always single-chunk, so contributing at chunk level would
double-count against the aliased hash.

copy_file_satellites gains one arm: attachments are DUPLICATED, since
the key is file_id and the copy is a different file, with uploaded_by
carried over — the person who supplied the bytes did not change because
someone copied the file. Each duplicate takes its own reference, so the
bytes stay deduplicated while the mapping does not.

Both golden SQL tests updated: the new fragment lands inside the reap
predicate's NOT(...) group and as a summed term in the manifest
recompute. Verified on a scratch PG with every migration applied — the
attachment duplicates to 2 rows holding 2 references with provenance
intact, while the content-keyed thumbnail stays 1 row reachable from
both files.
2026-08-30 13:41:04 +02:00

158 lines
8.9 KiB
PL/PgSQL

-- Step 9 of `docs/plan/derived-blobs.md` — the file-keyed half of the pair.
--
-- `content_derived_blobs` holds bytes that are a pure deterministic function
-- of a file's content, so they are keyed by that content and shared across
-- every file holding it. This table holds the opposite: bytes a USER supplied
-- or chose. Those must never be shared across files, and the key is what
-- enforces it.
--
-- The distinction is a security boundary, not a modelling preference. If a
-- client-uploaded preview were content-keyed, user A could upload a file plus
-- a preview that misrepresents it; when user B later uploads the same bytes,
-- dedup would match and B would be served A's preview. Content-keying is only
-- safe when the bytes are derivable from the content by the server — nothing
-- to poison, because anyone with the same input gets the same output.
--
-- Required now rather than deferred: the SPA already generates and PUTs
-- previews for PDFs, and there is no server-side regeneration path for them,
-- so the sidecar migration has nowhere else to put those bytes.
CREATE TABLE IF NOT EXISTS storage.file_attached_blobs (
file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('preview', 'subtitle', 'cover_art')),
variant TEXT NOT NULL,
blob_hash VARCHAR(64) NOT NULL,
content_type TEXT NOT NULL,
-- Provenance convention: NOT NULL and NO foreign key. A FK with
-- ON DELETE SET NULL loses the audit trail exactly when it matters most,
-- and without an ON DELETE clause it would block deleting a user
-- outright. Deleting the uploader must not rewrite history, so the id is
-- retained even once it no longer resolves. Rows imported with no known
-- uploader carry the all-zeros sentinel.
uploaded_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (file_id, kind, variant)
);
-- Reverse lookup for the reference recompute and for dedup_gc's reap
-- predicate: both ask "does any row reference this blob?".
CREATE INDEX IF NOT EXISTS idx_file_attached_blobs_blob_hash
ON storage.file_attached_blobs (blob_hash);
-- ── The routing rule, recorded on both tables ────────────────────────────
-- Choosing the wrong table is a silent poisoning bug rather than a compile
-- error, so the rule lives where an implementor will actually meet it.
COMMENT ON TABLE storage.file_attached_blobs IS
'User-supplied or user-chosen artifacts (client previews, subtitles, cover art) keyed by FILE. File-keyed on purpose: these bytes are not derivable from the file''s content, so sharing them across files with identical content would let one user''s upload be served for another user''s file. Server-derived bytes must NOT be stored here — see docs/plan/derived-blobs.md.';
COMMENT ON COLUMN storage.file_attached_blobs.file_id IS
'The file these bytes are attached to. ON DELETE CASCADE: the attachment has no meaning without it. Deleting the row does NOT release the blob reference — the owning service does that in its on_file_deleted hook.';
COMMENT ON COLUMN storage.file_attached_blobs.blob_hash IS
'The attached Blob. Reference HOLDER — bumps chunk_manifests.ref_count via DedupService::add_reference. Dedup still applies to the bytes themselves; what is forbidden is sharing the MAPPING across files.';
COMMENT ON COLUMN storage.file_attached_blobs.variant IS
'Opaque discriminator within a kind (preview | en | fr | cover...). New axes go inside this string, never into new columns.';
COMMENT ON COLUMN storage.file_attached_blobs.uploaded_by IS
'Who supplied these bytes. Retained after the user is deleted — deleting a user must not rewrite provenance. The only trace that an Editor on a shared file replaced the owner''s preview.';
COMMENT ON TABLE storage.content_derived_blobs IS
'Server-derived artifacts (thumbnails, transcodes) keyed by the BLAKE3 of their SOURCE content. Content-keyed on purpose: identical content shares one derivation. ROUTING RULE — bytes that are a pure deterministic function of the file''s content belong here; bytes that are user-supplied or user-chosen belong in storage.file_attached_blobs, which is file-keyed and never shared. See docs/plan/derived-blobs.md.';
-- ── Teach the copy fan-out about it ──────────────────────────────────────
--
-- Only the attached-blobs arm is new versus `20261019000000`; everything
-- else is that definition verbatim. Adding a file-keyed table is now one
-- edit in one function, which is the whole point of having consolidated the
-- two copy paths first.
CREATE OR REPLACE FUNCTION storage.copy_file_satellites(
p_old_ids UUID[],
p_new_ids UUID[]
) RETURNS void AS $$
DECLARE
v_unmatched TEXT[];
BEGIN
IF p_old_ids IS NULL OR cardinality(p_old_ids) = 0 THEN
RETURN;
END IF;
IF p_new_ids IS NULL OR cardinality(p_old_ids) <> cardinality(p_new_ids) THEN
-- Positional correspondence is the whole interface; a length
-- mismatch would silently attach satellites to the wrong file.
RAISE EXCEPTION
'copy_file_satellites: id arrays must correspond positionally (% old vs % new)',
cardinality(p_old_ids), COALESCE(cardinality(p_new_ids), 0);
END IF;
-- 1. WebDAV dead properties. RFC 4918 §8.8 requires COPY to duplicate
-- them: properties describe the resource, and the copy is a resource.
INSERT INTO storage.webdav_dead_properties
(file_id, namespace, local_name, value)
SELECT m.new_id, dp.namespace, dp.local_name, dp.value
FROM unnest(p_old_ids, p_new_ids) AS m(old_id, new_id)
JOIN storage.webdav_dead_properties dp ON dp.file_id = m.old_id;
-- 2. A reference on the copied content, so deleting the original cannot
-- reap bytes the copy still needs. Read from the NEW rows rather than
-- the old ones: that is what makes an unreferenceable copy impossible
-- to create, since a row that failed to insert contributes nothing.
SELECT storage.add_blob_references(array_agg(f.blob_hash))
INTO v_unmatched
FROM unnest(p_new_ids) AS n(id)
JOIN storage.files f ON f.id = n.id
WHERE NOT f.is_trashed;
IF v_unmatched IS NOT NULL AND cardinality(v_unmatched) > 0 THEN
-- Warn, do not abort. A missing registry row means the SOURCE file
-- was already broken; the copy merely inherits it. Failing here
-- would abort an entire folder copy over one pre-existing fault,
-- which is worse than completing it and reporting. The blob-level
-- audit jobs are what surface the underlying breakage.
RAISE WARNING
'copy_file_satellites: % copied file(s) reference a blob with no registry row (first: %); source was already broken',
cardinality(v_unmatched), v_unmatched[1];
END IF;
-- 3. File-keyed attachments — client previews, subtitles, cover art.
-- DUPLICATED rather than shared, because the key is `file_id` and the
-- copy is a different file. `uploaded_by` carries over: the person
-- who supplied the bytes did not change because someone copied the
-- file, and rewriting it to the copier would forge provenance.
--
-- Each duplicated row is a new reference on the same blob, so the
-- bytes are still deduplicated — it is the MAPPING that must not be
-- shared, not the content.
WITH copied AS (
INSERT INTO storage.file_attached_blobs
(file_id, kind, variant, blob_hash, content_type, uploaded_by)
SELECT m.new_id, a.kind, a.variant, a.blob_hash, a.content_type, a.uploaded_by
FROM unnest(p_old_ids, p_new_ids) AS m(old_id, new_id)
JOIN storage.file_attached_blobs a ON a.file_id = m.old_id
RETURNING blob_hash
)
SELECT storage.add_blob_references(array_agg(blob_hash))
INTO v_unmatched
FROM copied;
IF v_unmatched IS NOT NULL AND cardinality(v_unmatched) > 0 THEN
RAISE WARNING
'copy_file_satellites: % attached blob(s) reference no registry row (first: %); source was already broken',
cardinality(v_unmatched), v_unmatched[1];
END IF;
-- ── Deliberately absent ──────────────────────────────────────────────
--
-- storage.comments (future): NOT copied. A copy is a new artifact; the
-- discussion belongs to the original.
--
-- content_derived_blobs, blob_extracted_text, faces.faces: content-keyed.
-- The copy shares the source's hash, so it already sees them — copying
-- would duplicate rows that are keyed on the very thing being shared.
--
-- storage.favorites, recent_items, shares: properties of the ORIGINAL's
-- relationship to users, not of its content.
END;
$$ LANGUAGE plpgsql;