npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-client

Requires 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_ICONSpeople, 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_id is text, not uuid. A project keyed by bigserial or ULID can use this; one that assumed UUIDs everywhere could not.
  • parent_node_id is text 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 integration

It 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.