158 lines
8.9 KiB
PL/PgSQL
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;
|