From 16f73cc802a9ee61d316d2ef54ea5c33aa64f726 Mon Sep 17 00:00:00 2001 From: Marc-Antoine Parent Date: Thu, 3 Sep 2026 09:06:28 -0400 Subject: [PATCH] ENG-2229 Add a `source_path` column to FileReference --- packages/database/src/dbTypes.ts | 4 + .../database/src/lib/__tests__/files.test.ts | 164 ++++++++++++++++++ packages/database/src/lib/files.ts | 6 + ...60903120000_file_reference_source_path.sql | 20 +++ packages/database/supabase/schemas/assets.sql | 9 +- 5 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 packages/database/src/lib/__tests__/files.test.ts create mode 100644 packages/database/supabase/migrations/20260903120000_file_reference_source_path.sql diff --git a/packages/database/src/dbTypes.ts b/packages/database/src/dbTypes.ts index d9e26c63c..0037ea42b 100644 --- a/packages/database/src/dbTypes.ts +++ b/packages/database/src/dbTypes.ts @@ -599,6 +599,7 @@ export type Database = { last_modified: string original: boolean | null source_local_id: string + source_path: string | null space_id: number variant: Database["public"]["Enums"]["ContentVariant"] | null } @@ -609,6 +610,7 @@ export type Database = { last_modified: string original?: boolean | null source_local_id: string + source_path?: string | null space_id: number variant?: Database["public"]["Enums"]["ContentVariant"] | null } @@ -619,6 +621,7 @@ export type Database = { last_modified?: string original?: boolean | null source_local_id?: string + source_path?: string | null space_id?: number variant?: Database["public"]["Enums"]["ContentVariant"] | null } @@ -1345,6 +1348,7 @@ export type Database = { filepath: string | null last_modified: string | null source_local_id: string | null + source_path: string | null space_id: number | null } Relationships: [] diff --git a/packages/database/src/lib/__tests__/files.test.ts b/packages/database/src/lib/__tests__/files.test.ts new file mode 100644 index 000000000..5f6923ea5 --- /dev/null +++ b/packages/database/src/lib/__tests__/files.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DGSupabaseClient } from "../client"; +import { addFile } from "../files"; + +type Result = { + data?: unknown; + error: { code?: string; message: string } | null; +}; + +const thenable = (result: Result) => ({ + then: ( + resolve: (value: Result) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(result).then(resolve, reject), +}); + +type Row = { + source_local_id?: unknown; + space_id?: unknown; + filepath?: unknown; + filehash?: unknown; + source_path?: unknown; +}; + +/** + * A FileReference table whose insert rejects a repeated (source_local_id, space_id, + * filepath) with 23505, the way the real primary key does, so the duplicate-key branch + * of addFile is exercised rather than simulated. + */ +const makeClient = () => { + const rows = new Map(); + const key = ({ source_local_id, space_id, filepath }: Row) => + [source_local_id, space_id, filepath].join(" "); + + const upload = vi.fn().mockResolvedValue({ error: null }); + + const insert = vi.fn((row: Row) => { + if (rows.has(key(row))) + return thenable({ error: { code: "23505", message: "duplicate key" } }); + rows.set(key(row), { ...row }); + return thenable({ error: null }); + }); + + const update = vi.fn((patch: Row) => { + const match: Row = {}; + const builder = { + eq: vi.fn((column: keyof Row, value: unknown) => { + match[column] = value; + return builder; + }), + then: ( + resolve: (value: Result) => unknown, + reject?: (reason: unknown) => unknown, + ) => { + const existing = rows.get(key(match)); + if (existing) Object.assign(existing, patch); + return Promise.resolve({ error: null } as Result).then(resolve, reject); + }, + }; + return builder; + }); + + const client = { + rpc: vi.fn((_fn: string, { hashvalue }: { hashvalue: string }) => + Promise.resolve({ + data: [...rows.values()].some((row) => row.filehash === hashvalue), + error: null, + }), + ), + storage: { from: vi.fn(() => ({ upload })) }, + from: vi.fn(() => ({ insert, update })), + } as unknown as DGSupabaseClient; + + return { client, rows, upload, insert, update }; +}; + +const publish = ({ + client, + sourcePath, + body = "bytes", + fname = "https://firebasestorage.example/imgs/app/graph/lqp2ioVNC3.png", +}: { + client: DGSupabaseClient; + sourcePath?: string | null; + body?: string; + fname?: string; +}) => + addFile({ + client, + spaceId: 20, + sourceLocalId: "node-1", + fname, + sourcePath, + mimetype: "image/png", + created: new Date("2026-09-01T10:00:00Z"), + lastModified: new Date("2026-09-01T10:00:00Z"), + content: new TextEncoder().encode(body).buffer, + }); + +describe("addFile", () => { + let harness: ReturnType; + + beforeEach(() => { + harness = makeClient(); + }); + + it("stores the source path on the inserted reference", async () => { + await publish({ client: harness.client, sourcePath: "diagram.png" }); + + expect([...harness.rows.values()]).toEqual([ + expect.objectContaining({ source_path: "diagram.png" }), + ]); + }); + + it("updates the stored name when the same reference is republished under a changed name", async () => { + await publish({ client: harness.client, sourcePath: "diagram.png" }); + await publish({ client: harness.client, sourcePath: "figure-2.png" }); + + expect(harness.update).toHaveBeenCalledWith( + expect.objectContaining({ source_path: "figure-2.png" }), + ); + expect([...harness.rows.values()]).toEqual([ + expect.objectContaining({ source_path: "figure-2.png" }), + ]); + }); + + it("clears the stored source path when a republish supplies null", async () => { + await publish({ client: harness.client, sourcePath: "diagram.png" }); + await publish({ client: harness.client, sourcePath: null }); + + expect([...harness.rows.values()]).toEqual([ + expect.objectContaining({ source_path: null }), + ]); + }); + + it("does not clear the stored source path when a republish omits it", async () => { + await publish({ client: harness.client, sourcePath: "diagram.png" }); + await publish({ client: harness.client }); + + expect([...harness.rows.values()]).toEqual([ + expect.objectContaining({ source_path: "diagram.png" }), + ]); + }); + + it("records no name for a caller that does not supply one", async () => { + await publish({ client: harness.client }); + + expect([...harness.rows.values()]).toEqual([ + expect.objectContaining({ source_path: null }), + ]); + }); + + it("uploads the bytes once when two references share content", async () => { + await publish({ client: harness.client, sourcePath: "diagram.png" }); + await publish({ + client: harness.client, + sourcePath: "same-bytes.png", + fname: "https://firebasestorage.example/imgs/app/graph/OtherUid00.png", + }); + + expect(harness.upload).toHaveBeenCalledTimes(1); + expect(harness.rows.size).toBe(2); + }); +}); diff --git a/packages/database/src/lib/files.ts b/packages/database/src/lib/files.ts index d63b1cf94..0933db1d4 100644 --- a/packages/database/src/lib/files.ts +++ b/packages/database/src/lib/files.ts @@ -7,6 +7,7 @@ export const addFile = async ({ spaceId, sourceLocalId, fname, + sourcePath, mimetype, created, lastModified, @@ -15,7 +16,10 @@ export const addFile = async ({ client: DGSupabaseClient; spaceId: number; sourceLocalId: string; + /** What the content refers to, stored in `filepath`: the link or URL as the content wrote it. */ fname: string; + /** Where the publishing platform kept the asset, stored in `source_path`, when known. */ + sourcePath?: string | null; mimetype: string; created: Date; lastModified: Date; @@ -50,6 +54,7 @@ export const addFile = async ({ last_modified: lastModified.toISOString(), filepath: fname, filehash: hashvalue, + source_path: sourcePath ?? null, created: created.toISOString(), }); @@ -62,6 +67,7 @@ export const addFile = async ({ last_modified: lastModified.toISOString(), filehash: hashvalue, created: created.toISOString(), + ...(sourcePath === undefined ? {} : { source_path: sourcePath }), }) .eq("source_local_id", sourceLocalId) .eq("space_id", spaceId) diff --git a/packages/database/supabase/migrations/20260903120000_file_reference_source_path.sql b/packages/database/supabase/migrations/20260903120000_file_reference_source_path.sql new file mode 100644 index 000000000..0a8902a63 --- /dev/null +++ b/packages/database/supabase/migrations/20260903120000_file_reference_source_path.sql @@ -0,0 +1,20 @@ +ALTER TABLE public."FileReference" ADD COLUMN IF NOT EXISTS source_path character varying; + +COMMENT ON COLUMN public."FileReference".source_path +IS 'Where the publishing platform kept the asset: a path in a vault, or a name in a flat asset namespace. Distinct from filepath, which holds what the content refers to. Null when the publisher did not record one. A destination names an imported asset from this, so it never has to inspect filepath for provenance.'; + +CREATE OR REPLACE VIEW public.my_file_references AS +SELECT + source_local_id, + space_id, + filepath, + filehash, + created, + last_modified, + source_path +FROM public."FileReference" + LEFT OUTER JOIN public.my_accessible_resources() AS ra USING (space_id, source_local_id) +WHERE ( + space_id = any(public.my_space_ids('reader')) + OR (space_id = any(public.my_space_ids('partial')) AND ra.space_id IS NOT NULL) +); diff --git a/packages/database/supabase/schemas/assets.sql b/packages/database/supabase/schemas/assets.sql index 9510b4111..8a2d203bd 100644 --- a/packages/database/supabase/schemas/assets.sql +++ b/packages/database/supabase/schemas/assets.sql @@ -7,7 +7,8 @@ CREATE TABLE IF NOT EXISTS public."FileReference" ( last_modified timestamp without time zone NOT NULL, -- not allowed virtual with user types variant public."ContentVariant" GENERATED ALWAYS AS ('full') STORED, - original BOOLEAN GENERATED ALWAYS AS (true) STORED + original BOOLEAN GENERATED ALWAYS AS (true) STORED, + source_path character varying ); ALTER TABLE ONLY public."FileReference" ADD CONSTRAINT "FileReference_pkey" PRIMARY KEY (source_local_id, space_id, filepath); @@ -23,6 +24,9 @@ CREATE INDEX file_reference_filepath_idx ON public."FileReference" USING btree ( CREATE INDEX file_reference_filehash_idx ON public."FileReference" USING btree (filehash); ALTER TABLE public."FileReference" OWNER TO "postgres"; +COMMENT ON COLUMN public."FileReference".source_path +IS 'Where the publishing platform kept the asset: a path in a vault, or a name in a flat asset namespace. Distinct from filepath, which holds what the content refers to. Null when the publisher did not record one. A destination names an imported asset from this, so it never has to inspect filepath for provenance.'; + CREATE OR REPLACE VIEW public.my_file_references AS SELECT source_local_id, @@ -30,7 +34,8 @@ SELECT filepath, filehash, created, - last_modified + last_modified, + source_path FROM public."FileReference" LEFT OUTER JOIN public.my_accessible_resources() AS ra USING (space_id, source_local_id) WHERE (