An external field is a model attribute whose contents live in an object or
key-value store while the row lives in the database. The table column holds
only the object key; the ORM keeps the two in step, so the link between a row
and its blob is not something you maintain by hand.Use it for the payloads that do not belong in a row — avatars, attachments,
rendered documents, large JSON blobs — while keeping them addressable through
the model that owns them.Setting one up#
Connect a store and register it by name#
import { Prorm } from 'ts-prorm-orm';
import { S3Store } from 'ts-prorm-orm/nosql';
const prorm = new Prorm({ dialect: 'postgres', database: 'app' });
const s3 = new S3Store({ region: 'us-east-1' });
await s3.connect();
prorm.registerStore('assets', s3, { defaultBucket: 'avatars' });
Mark the field#
import { Model, DataTypes, ExternalField, Table, Column, PrimaryKey, AutoIncrement } from 'ts-prorm-orm';
@Table({ tableName: 'users' })
export class User extends Model {
@Column(DataTypes.INTEGER()) @PrimaryKey @AutoIncrement
declare id: number;
@Column(DataTypes.STRING(100))
declare name: string;
@ExternalField({ store: 'assets', contentType: 'image/png' })
declare avatar: Buffer;
}
Add the model and sync#
const UserModel = prorm.addModel(User);
await prorm.sync();
@ExternalField registers a companion column for you — avatarKey by default —
so the CREATE TABLE gains a string column holding the key, and nothing else
changes about the row. On SQLite, with a second field that sets
keyColumn: 'reportRef':Using one#
const ada = await UserModel.create({ name: 'Ada' });
ada.avatar = pngBytes;
await ada.save(); // PUT to S3, then the row (carrying avatarKey)
const fetched = await UserModel.findByPk(ada.id);
await fetched.loadAvatar(); // GET from S3
fetched.avatar; // Buffer
await fetched.hasAvatar(); // does the object exist?
await fetched.deleteAvatar(); // delete the object, null the key column
await fetched.destroy(); // deleting the row deletes its objects too
Every field x gets four methods:| Method | Does |
|---|
loadX() | Read from the store into x. Returns null when the key column is empty. |
saveX(value?) | Write to the store and persist the key. With no argument, writes whatever was assigned to x. |
deleteX() | Delete the object and null the key column. |
hasX() | Whether the object exists in the store. |
What each step sends, in order, recorded against SQLite and an in-memory store
(the adapter is shown below):toJSON() carries the key columns, never the payloads:{"id":1,"name":"Ada","avatarKey":"avatar/users-1","reportRef":"report/users-1","createdAt":"…","updatedAt":"…"}
[!WARNING]
The key is an internal storage path. If rows go straight to an API client,
drop avatarKey from the response and hand out a URL your server controls
(or a signed URL from the store) instead.
How it behaves#
The value is a non-enumerable accessor. user.avatar is defined with
Object.defineProperty, deliberately non-enumerable, so save()'s sweep over
the instance's own keys never tries to write a Buffer into a column that does
not exist. Assigning to it records a pending write; nothing goes over the wire
until you save.save() is wrapped. Pending payloads are uploaded first, then the row is
written with the key column already set — one row write, not an insert followed
by an update.destroy() is wrapped. Each field's object is deleted before the row is.
If the store is unreachable, the delete is swallowed and the row still goes:
an orphaned object is better than a half-deleted record. Sweep orphans with a
periodic job if that matters to you.Keys are generated as `${keyPrefix}${tableName}-${id}` — for example
avatar/users-42. A row with no primary key yet (saving a payload before the
insert) falls back to a base-36 timestamp. An existing key is reused, so
re-saving overwrites the same object rather than leaking a new one.Options#
| Option | Default | Purpose |
|---|
store | — | The name the store was registered under. Required. |
bucket | the store's defaultBucket | Bucket/container; becomes a key prefix on key-value stores. |
keyColumn | <field>Key | The column holding the object key. |
keyPrefix | <field>/ | Prefix on generated keys. |
contentType | — | Recorded on write where the store supports it. |
encoding | buffer | buffer, utf8, or json — parsed on read, stringified on write. |
import { Model, ExternalField } from 'ts-prorm-orm';
class Analysis extends Model {
@ExternalField({ store: 'assets', bucket: 'documents', encoding: 'json', keyColumn: 'reportRef' })
declare report: { rows: number[]; generatedAt: string };
}
With encoding: 'json', assigning { rows: [1, 2], generatedAt: '2026-09-26' }
stores the JSON text, and loadReport() gives the object back — not a
Buffer.Stores#
Any shipped store works without an adapter — S3, MinIO, R2, GCS, Azure Blob,
Redis and friends all name their methods differently (putObject vs
uploadObject vs uploadBlob), and registerStore() normalises them:prorm.registerStore('assets', s3Store, { defaultBucket: 'avatars' });
prorm.registerStore('cache', redisStore);
Key-value stores have no bucket concept, so a configured bucket becomes a key
prefix and binary payloads round-trip through base64.For anything unrecognised, pass an object implementing ExternalStoreAdapter:import type { ExternalStoreAdapter } from 'ts-prorm-orm';
const adapter: ExternalStoreAdapter = {
engine: 'my-store',
usesContainer: false,
async put(container, key, value, opts) { /* … */ },
async get(container, key) { /* … */ return Buffer.from(''); },
async delete(container, key) { /* … */ },
async exists(container, key) { return true; },
};
prorm.registerStore('custom', adapter, { defaultBucket: 'things' });
An in-memory store for tests#
The same interface makes a fake store a dozen lines, so tests exercise the real
save / load / destroy paths without a bucket:import type { ExternalStoreAdapter } from 'ts-prorm-orm';
export function memoryStore() {
const objects = new Map<string, Buffer>();
const adapter: ExternalStoreAdapter = {
engine: 'memory',
usesContainer: true,
async put(container, key, value) {
objects.set(`${container}/${key}`, Buffer.isBuffer(value) ? value : Buffer.from(String(value)));
},
async get(container, key) {
return objects.get(`${container}/${key}`) ?? null;
},
async delete(container, key) {
objects.delete(`${container}/${key}`);
},
async exists(container, key) {
return objects.has(`${container}/${key}`);
},
};
return { adapter, objects };
}
const { adapter, objects } = memoryStore();
prorm.registerStore('assets', adapter, { defaultBucket: 'avatars' });
// … create, save, destroy …
expect([...objects.keys()]).toEqual([]); // destroy() removed every object
toExternalStoreAdapter(store) is the normaliser, exported if you want to wrap
a store yourself. It throws ExternalStoreError for a store it cannot adapt,
and for an operation that needs a container on a store that has none.Introspection#
import { getExternalFields, getExternalField, hasExternalFields } from 'ts-prorm-orm';
hasExternalFields('User'); // boolean
getExternalFields('User'); // ExternalFieldMeta[]
getExternalField('User', 'avatar'); // one field's resolved metadata
Things worth knowing#
There is no transaction across the two systems. The object write and the
row write are separate. A crash between them leaves an object with no row
(harmless, sweepable) or — if you write the key yourself — a row pointing at
an object that is not there. The wrapped save() orders them so the harmless
case is the one you get.
loadX() is explicit. Reading a row does not fetch its objects; nothing
is downloaded until you ask. That keeps findAll() over a thousand rows from
becoming a thousand GETs.
Bulk paths do not participate. These accessors are attached to instances,
so Model.update({…}, { where }) and other static writes do not touch the
store.
Modified at 2026-10-04 00:40:16