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

@~lyre/file

v0.4.0

Published

File/asset layer for SvelteKit + Drizzle. Pluggable storage drivers (local/S3/remote), md5-dedup uploads, responsive image variants, remote-URL ingestion, polymorphic attachments, and a turnkey stream/upload route provider.

Readme

@~lyre/file

File/asset layer for Drizzle + Postgres (Node/TypeScript). Pluggable storage drivers (local / S3 / remote), md5-dedup uploads, responsive image variants, remote-URL ingestion, polymorphic attachments, and a turnkey stream + upload route provider.

TypeScript port of the Laravel lyre/file package. (That PHP package lives at packages/file in this repo; this is the TS sibling.)

Install

npm install @~lyre/file drizzle-orm

drizzle-orm is the only required peer. Two optional extras unlock features when present, and are never imported otherwise:

| Package | Enables | | --- | --- | | sharp | Image dimensions + sm/md/lg responsive variants | | @aws-sdk/client-s3 | The s3 storage driver | | @~lyre/model | The ./models entry point (File CRUD model) |

No assumptions about your schema

files.tenant_id, files.owner_user_id and attachments.attachable_id are plain uuids with no foreign keys by default, so the package imports none of your tables. It works in an app that has no tenants/users at all — one keyed by app slug, workspace, or anything else. Ownership is enforced by your application.

Register before the schema is imported to change either the column type or the table each column references:

// db/schema/file-owners.ts — its own module, so it is evaluated first
import { registerFileOwners } from '@~lyre/file/owners';
import { tenants } from './tenants';
import { users } from './users';

// An app with uuid keys: add real foreign keys.
registerFileOwners({ tenant: { column: tenants.id }, owner: { column: users.id } });
// An app whose ids are NOT uuids — e.g. tenant ids like '82', and owner rows
// spread across tables with different key types. Postgres rejects '82'::uuid,
// so the type must be declared.
registerFileOwners({ tenant: { type: 'text' }, attachable: { type: 'text' } });
// db/schema/files.ts
import './file-owners.js';        // MUST precede the export below
export * from '@~lyre/file/schema';

The separate module matters. ESM hoists every import above the module body, so a registerFileOwners(...) call sitting next to export * from '.../schema' would run after the table was already built.

attachable_id is polymorphic and never carries a foreign key, so only its type is configurable. When one attachable table has a bigserial id and another has a text slug, 'text' is the only type that spans both.

File lifecycle

A row created with referenceOnly points at someone else's host, so it can rot without anything here changing. files.status (active | unreachable | unlinked) plus last_checked_at make that an indexed column rather than a guess:

import { refreshRemoteFile, markFileViewed } from '@~lyre/file';

await refreshRemoteFile(db, files, fileId);   // probes the URL, records the result
await markFileViewed(db, files, fileId);      // stamps viewed_at when served

refreshRemoteFile HEADs the URL and falls back to a ranged GET (some CDNs answer HEAD with 405 while serving GET fine). Network failures record unreachable rather than throwing, so a sweep over many rows completes. unlinked is never inferred — set it deliberately when you know the original is gone.

Resolving file references

resolveFileTokens walks any object and substitutes <prefix>:<id> strings via a lookup you supply. Use it when a producer should be handed opaque ids instead of URLs — the ids are stable, and a URL that changes (or was never meant to be public) is resolved at the boundary rather than copied around:

import { resolveFileTokens } from '@~lyre/file';

const urls = new Map([['7f3a', 'https://cdn.example.com/a.png']]);
resolveFileTokens(payload, { prefix: 'media', lookup: (id) => urls.get(id) });
// { attachments: ['media:7f3a', 'media:ghost'] } → { attachments: ['https://…/a.png'] }

An id the lookup cannot resolve is removed — dropped from arrays, nulled on object properties — so a dangling reference never survives. The prefix and lookup are yours; this knows nothing about what produced the value.

Storage

import { configureStorage, S3StorageDriver } from '@~lyre/file';

configureStorage(new S3StorageDriver({ bucket: 'assets', region: 'eu-west-1' }));

Or via env: STORAGE_DRIVER=local|s3|remote, plus STORAGE_LOCAL_ROOT or S3_BUCKET / S3_REGION / S3_ENDPOINT / S3_FORCE_PATH_STYLE and standard AWS credentials. S3_ENDPOINT + S3_FORCE_PATH_STYLE cover R2, MinIO and Spaces.

An unknown or unconfigured driver throws. It does not fall back to another disk. An earlier version silently wrote to local disk when S3 was requested, while the file row still recorded the requested backend — rows that pointed at objects which were never uploaded. A misconfiguration should fail at startup.

Uploading

import { uploadFile } from '@~lyre/file';
import { files } from './db/schema';

const record = await uploadFile(db, files, {
  tenantId,
  bytes,
  filename: 'logo.png',
  mimeType: 'image/png',
  publicBase: '/api/v1/files'   // where you mounted the stream route
});

Uploads are md5-deduplicated per owner: re-uploading identical bytes bumps usage_count and returns the existing row instead of storing again.

Attachments

One file can hang off many rows, and a row can hold an ordered list of files — without either side knowing the other's schema.

import { attach, listAttachments, detach } from '@~lyre/file';

await attach(db, { attachments, files }, {
  attachableType: 'prompt_block',   // any app-defined discriminator
  attachableId: blockId,
  fileId: record.id,
  order: 0
});

const assets = await listAttachments(db, { attachments, files }, {
  attachableType: 'prompt_block',
  attachableId: blockId
});

attach is idempotent — re-attaching the same file updates its position rather than duplicating.

Route provider

filesProvider contributes GET <mount>/[file] (stream) and POST <mount> (upload) to a @~lyre/model platform hook. It ships from its own subpath@~lyre/file/provider, not the root entry — so mounting HTTP routes is always a deliberate opt-in.

Storage and both authorization gates are injected; that DI is what keeps the package portable, so they cannot be defaulted:

import { filesProvider } from '@~lyre/file/provider';

filesProvider({
  // Reads are authorized FIRST, and the grant scopes the lookup.
  authorizeRead: async (event, { slug }) => {
    const session = await sessionFor(event);
    if (!session) return null;                       // 403
    return { tenantId: session.tenantId, viewerId: session.userId };
  },
  // MUST filter on grant.tenantId — a slug is guessable, not a capability.
  getFile: (slug, grant) => getFileForStream(slug, grant.tenantId),
  recordView: (fileId, viewerId) => files.recordView(fileId, viewerId),
  upload: (input) => uploadFileToStorage(input),
  authorizeUpload: async (event, fields) => {
    if (!isAdmin(event)) return null;                // 403
    return { tenantId: fields.tenantId!, ownerUserId: currentUserId(event) };
  },
  ownerField: 'tenantId'   // multipart field carrying the owner id
});

Slugs derive from the filename (logo.pnglogo, with a random suffix only on collision), so they are guessable and are not a capability. authorizeRead is where access is decided, and getFile must constrain the query to the granted scope — returning a file outside it is a cross-tenant leak the package cannot detect.

Streamed bytes are sent private, max-age=3600 with X-Content-Type-Options: nosniff and an explicit Content-Disposition. image/svg+xml is not in the default mime allowlist: an SVG served inline from your own origin is a stored-XSS vector. Opt in via allowedMime only if you serve it from a separate origin.

Media tokens

Agents and stored content reference files as opaque media:<id> tokens, never URLs — a moved asset is then a data fix, not a content rewrite. Resolve them at the last moment:

const reply = await repo.resolveMediaUrls(modelOutput, {
  prefix: 'media',
  dropObjectsMissing: ['url']   // prune attachments whose url didn't resolve
});

One batched findManyByIds per call, not a query per token. Only active rows with a url resolve — unlinked (source dropped the original) and unreachable (last probe failed) are withheld, and anything unresolved is dropped rather than shipped as a dangling media:abc string.

dropObjectsMissing exists because an unresolved token is removed from an array but nulled on an object property — correct in general, since the walker cannot know which properties are load-bearing, but wrong for an attachment list where {url: null, type: 'image'} is malformed. Pass the property names an object cannot survive without.

collectFileTokenIds(value, prefix) is available separately when you need the ids without substituting.

Fetching remote URLs

Remote ingestion and the refresh sweep validate every URL through assertPublicHttpUrl / safeFetch: http(s) only, no embedded credentials, no private / loopback / link-local / metadata addresses, and every redirect hop re-checked. Validation happens at store time as well as fetch time, because a reference-only ingestion records a URL that the background sweep fetches later.

Both are exported for hosts that fetch media themselves. Residual risk: DNS rebinding is not closed — pass allowedHosts where the host set is known.

Notes

Node-only — it uses node:fs, and both sharp and the AWS SDK are loaded lazily at runtime.