@speles7172/file-client
v0.4.0
Published
Entity-linked file system over Postgres — folders, albums and attachments for any entity, through an executor and an object store you supply.
Readme
@speles7172/file-client
Files and folders, linked to anything. One idea: a node.
A folder is a node. An album is a node. So is any record in your application — a project, a vendor, an invoice. Files live in nodes, folders hang off nodes, and every operation takes a node id. That is what lets one file browser work on a standalone Files page and embedded in a record's detail page without a second implementation: only the node id differs.
npm install @speles7172/file-clientRequires Node 22+ and Postgres. No dependencies at all — you supply the two things that would otherwise force them: a way to run a query, and somewhere to put the bytes.
The React half is @speles7172/file-console.
Getting one
import { createFileSystem } from '@speles7172/file-client';
const files = createFileSystem({
execute: pool.query.bind(pool), // any (sql, params) => { rows }
store, // ~20 lines over the AWS SDK, below
bucket: 'acme-files',
keyPrefix: 'files',
directory, // optional: your entities, as nodes
authorizer, // optional, and you want it
});Node ids
Everything is addressed by one string, and it is the only thing the browser sends:
| id | what it is |
|---|---|
| '' | the root listing |
| folder:<uuid> | a stored folder — a system root, a folder, an album |
| <entityType>:<id> | one of your records: project:7, vendor:9f1c… |
The prefix is the entity type, so you add a kind of node by naming it. The
split is on the first colon, because an entity id is opaque and may contain
one. folder is reserved — it is how a file filed into a folder is recorded.
The object store
Three methods. The AWS SDK is 3 MB and not every bucket is S3, so it lives in your application next to your credentials:
const store: FileObjectStore = {
presignUpload: ({ bucket, key, contentType, expiresIn }) =>
getSignedUrl(s3, new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: contentType }), { expiresIn }),
presignDownload: ({ bucket, key, contentDisposition, expiresIn }) =>
getSignedUrl(s3, new GetObjectCommand({ Bucket: bucket, Key: key, ResponseContentDisposition: contentDisposition }), { expiresIn }),
delete: async ({ bucket, key }) => { await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })); },
};contentDisposition arrives RFC 6266 encoded — pass it straight through. Do not
build it yourself: a raw non-ASCII filename makes S3 reject the presigned URL
outright, and the symptom is that downloads break only for the files whose names
are not English.
Your entities in the tree
Optional. Without it you get a folder tree and files attached to entities by id; with it, records appear in the tree and can be browsed into.
const directory: EntityDirectory<Session> = {
// Return only what this caller may see: this is filtering at the source.
children: async (parent, session) => {
if (parent.kind === 'root') return listProjects(session).map(toNode);
if (parent.kind === 'entity') return listSubProjects(parent.entityId).map(toNode);
return [];
},
describe: async (entityType, entityId) => {
const project = await findProject(entityId);
return project === null ? null : { entityType, entityId, name: project.name };
},
};parentNodeId on each node is what breadcrumbs follow upwards. A chain that
loops is refused after 32 steps rather than hanging the request.
Declaring entityRoots puts a virtual folder per type in the tree. Each may say
what it holds, so a console can draw it as itself:
entityRoots: [
{ entityType: 'user', name: 'People', icon: 'people' },
{ entityType: 'snapshot', name: 'Backups', icon: 'backup' },
],icon is a name from FILE_NODE_ICONS — people, organisation,
records, backup, archive, folder — not a component. What a folder looks
like stays the console's decision, in one module; the declaration only says what
is in it. An unrecognised name is refused rather than quietly ignored, and
leaving it out draws what it drew before the field existed.
Who may see what
Not implemented here, deliberately — which people may read which folders is a question about your roles, your tenancy and your org chart.
const authorizer: FileAuthorizer<Session> = {
canView: (node, session) => node.entityId === null || session.projects.has(node.entityId),
canWrite: (node, session) => session.canEdit && node.folder?.kind !== 'system',
};node.entityType / node.entityId are the anchor — the nearest entity above
this node, denormalised onto the row — so one check covers everything under a
record without walking the tree yourself.
Omitting the authorizer means no restriction: everything is visible and writable, and your endpoint is the only gate. That is fine for an internal tool where everyone who reaches the page may see every file, and a serious mistake anywhere else.
Uploading is two steps
const ticket = await files.createUploadUrl(
{ actorId: user.id, nodeId, filename: 'plan.pdf', contentType: 'application/pdf' },
session,
);
// the browser PUTs the bytes to ticket.uploadUrl…
await files.completeUpload({ fileId: ticket.fileId }, session);The row is written when the URL is issued, because the file id is a segment of
the object key. Until the upload is confirmed the row is a promise, not a
file: it is hidden from every listing, and sweepAbandonedUploads() clears the
ones that never arrived. Skipping the second call means the file never appears.
Copying shares the object
copyFile inserts a second row pointing at the same object. No bytes are
copied, so copying a 2 GB video is instant — and the object is only deleted once
the last row referencing it has gone.
The tables
import { fileTablesSql } from '@speles7172/file-client';
console.log(fileTablesSql()); // paste into a migration
await files.ensureTables(); // or, if you have no migration runner
await files.ensureSystemRoots([{ key: 'general', name: 'General' }], { actorId: 'system' });Three tables — file_folders, files, file_types — all renameable through
tables. Notable choices:
entity_idistext, notuuid. A project keyed bybigserialor ULID can use this; one that assumed UUIDs everywhere could not.parent_node_idistext NOT NULL, defaulting to''. A folder's parent may be another folder or one of your entities, and the composite node id is the only thing that names both.(bucket, object_key)is indexed but not unique, because a copy is a second row over one object.
Entry points
@speles7172/file-client— everything (Node, ESM + CommonJS).@speles7172/file-client/core— the dependency-free half: node ids, types, metadata rules,contentDisposition. This is what the console imports; it is bundled for a browser on every CI run so it stays importable there.
Verifying it
The unit tests need nothing. The SQL is only really proven against a server, so there is an integration suite that runs the same operations for real and skips without a database:
FILE_CLIENT_TEST_DATABASE_URL=postgres://…/db npx vitest run integrationIt covers what a fake executor cannot: the DDL applying twice to an empty
database, the partial-index upsert, the recursive sub-tree walk, the
single-statement copy, jsonb through the driver and back, ON DELETE SET
NULL, LIKE escaping, and a non-uuid entity_id. CI runs it against a
postgres:16 service container on every pull request.
What it does not do
No bucket, no lifecycle rule, no access control and no schedule. It writes to
whatever bucket you own, through an executor you supply, and
sweepAbandonedUploads() says what should be cleaned up — calling it on a
timer is your EventBridge rule or cron line. Same reasoning as
@speles7172/backup-client.
Licence
MIT.
