Skip to main content

Entities, relations and pagination

Fabric state is a key-value store, and working with it directly means assembling the key by hand every time, serializing the object, validating it on read, and remembering which records are related to which. @hlf-core/chaincode takes care of this routine with domain managers: we stop thinking in keys and start thinking in entities, while StateKey and serialization stay under the hood.

EntityManager: CRUD over state​

The manager hierarchy has two layers. The abstract EntityManager<T> provides get, save, remove, has, find and findPaginated. Its implementation EntityManagerImpl<T> adds serialization with validation: before a write the entity is run through ValidateUtil.validate and TransformUtil.fromClass, and on read it is restored via toEntity, validated again and augmented via loadDetails. In the typical case a domain manager only needs to implement two methods — toEntity, which turns raw data into a class, and prefix, which defines the key namespace:

import { EntityManagerImpl } from '@hlf-core/chaincode';
import { TransformUtil } from '@ts-core/common';
import { User } from '@project/common/hlf/user';

export class UserManager extends EntityManagerImpl<User> {
public toEntity(item: any): User {
return TransformUtil.toClass(User, item);
}
public get prefix(): string {
return User.PREFIX; // 'user'
}
}

The entity's uid serves as the record key, so get, save and has work directly on the object:

await manager.save(user);
const item = await manager.get(user.uid);
const exists = await manager.has(user.uid);

Moving fields into separate keys and lazy loading​

Not everything that belongs to an entity makes sense to store inside its record. A large or rarely needed field is better moved into its own state key and loaded only on an explicit request — via loadDetails and the details parameter. The canonical example is a user's cryptoKey: it is not in the user record itself, it lives under a separate key and is pulled in only when we list it in details:

export class UserManager extends EntityManagerImpl<User> {
protected getCryptoKeyUid(user: UID): string {
return `→${this.prefix}~cryptoKey:${getUid(user)}`;
}

protected async serialize(item: User): Promise<any> {
delete item.cryptoKey; // do not store the key inside the user record
return super.serialize(item);
}

public async loadDetails(item: User, details?: Array<keyof User>): Promise<void> {
await super.loadDetails(item, details);
if (_.isEmpty(details)) {
return;
}
let { cryptoKey } = item;
if (details.includes('cryptoKey') && _.isNil(cryptoKey)) {
item.cryptoKey = await this.stub.getState(this.getCryptoKeyUid(item), CryptoKey);
}
}

public async save(item: User): Promise<User> {
let { cryptoKey } = item;
if (!_.isNil(cryptoKey)) {
await this.stub.putState(this.getCryptoKeyUid(item), cryptoKey, { isValidate: true, isTransform: true, isSortKeys: true });
}
return super.save(item);
}

public async remove(item: UID): Promise<void> {
await this.stub.removeState(this.getCryptoKeyUid(item));
await super.remove(item);
}

public get prefix(): string {
return User.PREFIX;
}
}

// cryptoKey is pulled in only here:
const user = await manager.get(uid, ['cryptoKey']);

The point is that an ordinary read of a user does not drag the key along with it, and therefore does not pay for that state access — the heavy field arrives exactly when it is actually needed.

Relations between entities​

A key-value store has no foreign keys, so a many-to-many relation has to be maintained yourself — with separate index records. EntityRelation handles this: for each relation it writes two composite keys, a forward one and a reverse one, and this is not redundancy but a necessity — the forward key lets you list children by their parent, the reverse one lets you drop the relation knowing only the child. A manager that needs a secondary index sets up an EntityRelation in its constructor and calls it from save and remove:

import { EntityManagerImpl, EntityRelation, IEntityQuery, IStub } from '@hlf-core/chaincode';

export class DocumentManager extends EntityManagerImpl<Document> {
protected relation: EntityRelation;

constructor(logger: ILogger, stub: IStub) {
super(logger, stub);
this.relation = new EntityRelation(stub, Company.PREFIX, Document.PREFIX); // parent, child
}

protected getListQuery(company: UID, owner?: UID): IEntityQuery<Document> {
return this.relation.childListQuery(..._.compact([company, owner]));
}

public async save(item: Document): Promise<Document> {
await super.save(item);
await this.relation.childAdd(item, item.companyUid, item.ownerUid);
return item;
}

public async remove(item: UID): Promise<void> {
await this.relation.childRemove(item);
await super.remove(item);
}

public findByCompany(company: UID, owner?: UID): Promise<Array<Document>> {
return this.find(this.getListQuery(company, owner));
}
}

This way documents end up indexed by company and owner, and selecting all of them with a single range query costs exactly as much as listing records by a common prefix.

Pagination​

Large sets cannot be enumerated in full: both because of cost and because of Fabric's limits. That is why prefix queries are done page by page: findPaginated takes a request with a page size and a bookmark, and returns an IPaginationBookmark<T> with the items themselves and a bookmark for the next page. The client keeps firing requests until it sees isAllLoaded:

let bookmark = '';
do {
const page = await manager.findPaginated({ pageSize: 50, pageBookmark: bookmark, prefix });
handle(page.items);
bookmark = page.pageBookmark;
if (page.isAllLoaded) {
break;
}
} while (bookmark);

Tokens are built on top of these same managers — the model of accounts and balances is covered in the «Tokens» section.