Skip to content
Open
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
4 changes: 4 additions & 0 deletions packages/database/src/dbTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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: []
Expand Down
164 changes: 164 additions & 0 deletions packages/database/src/lib/__tests__/files.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Row>();
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<typeof makeClient>;

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);
});
});
6 changes: 6 additions & 0 deletions packages/database/src/lib/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const addFile = async ({
spaceId,
sourceLocalId,
fname,
sourcePath,
mimetype,
created,
lastModified,
Expand All @@ -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;
Expand Down Expand Up @@ -50,6 +54,7 @@ export const addFile = async ({
last_modified: lastModified.toISOString(),
filepath: fname,
filehash: hashvalue,
source_path: sourcePath ?? null,
created: created.toISOString(),
});

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
);
9 changes: 7 additions & 2 deletions packages/database/supabase/schemas/assets.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -23,14 +24,18 @@ 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,
space_id,
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 (
Expand Down