Implement a custom writer
Replace the Machine's shipped writer with your own, so you decide exactly what each recorded method call publishes.
Current SDK generation. This page documents the Machine that
@mintblue/sdk ships today. Everything on it was checked against version 9.6.0.
Generations sets out which document applies to the code
you installed.
The writer is the part of a Machine that turns a method call into a published
record. MintblueWriter does that with one encrypted, signed data output per
call, which is the right default. Write your own when you need something else in
the transaction: a second output carrying a timestamp, a file alongside the
call, or a different encryption decision per method.
Build your first machine is the starting point. This guide replaces one of its four parts.
What a writer has to implement
The interface as @mintblue/sdk 9.6.0 declares it, verbatim:
export interface Writer {
/** Writes action to the storage. */
write(action: ActionWithoutId): Promise<void>;
/** Handles errors or rejections that occur during the write process. */
handleError(error: any): void;
/**
* (Optional) Fetches the confirmation or status of the written data.
* Useful for ensuring data integrity or completion.
*/
fetchWriteStatus?(dataId: string): Promise<string>;
}Two required methods, one optional. The action you are handed is small, and the package declares it verbatim as:
export interface Action {
/** A unique ID of the Action */
id: string;
/** The action name/type/function */
f: string;
/** The action arguments */
args: any[];
}ActionWithoutId is that type without id, because the id is assigned once the
record exists rather than before it is written.
Step 1: Start from the skeleton
A writer needs a client and a project id. Building the client is asynchronous, so a constructor cannot do it. The shipped writer solves that with a static factory, and yours should follow the same shape, because that is what the wiring code expects to call.
const { Mintblue } = require('@mintblue/sdk');
class TimestampingWriter {
constructor(mintblue, projectId) {
this.mintblue = mintblue;
this.projectId = projectId;
}
static async create(sdkToken, projectId) {
const mintblue = await Mintblue.create({ token: sdkToken });
return new TimestampingWriter(mintblue, projectId);
}
async write(action) {}
handleError(error) {
console.error('writer error', error);
}
}
module.exports = { TimestampingWriter };Step 2: Write the record
write receives the action and decides what to publish. The minimum is one data
output carrying the method name and its arguments, because that is what the
reader needs in order to dispatch the call back into your application later.
class TimestampingWriter {
async write(action) {
try {
await this.mintblue.createTransaction({
project_id: this.projectId,
outputs: [
{
type: 'data',
value: { f: action.f, args: action.args },
encrypt: true,
sign: true,
},
],
});
} catch (error) {
this.handleError(error);
}
}
}Anything the reader does not need can go in a second output, where it can carry its own encryption decision. A timestamp readable without keys, next to a call that is not:
class TimestampingWriter {
async write(action) {
try {
await this.mintblue.createTransaction({
project_id: this.projectId,
outputs: [
{
type: 'data',
value: { f: action.f, args: action.args },
encrypt: true,
sign: true,
},
{
type: 'data',
value: { recorded_at: new Date().toISOString() },
encrypt: false,
sign: true,
},
],
});
} catch (error) {
this.handleError(error);
}
}
}Keep the call itself in one output. Splitting a single action across two outputs makes the reader's job harder for no gain, and the reader is the part you did not write.
Step 3: Handle errors deliberately
handleError is called by your own write, so what it does is your decision,
and the decision matters. A writer that logs and continues will let the
application carry on with state that no record supports. A writer that rethrows
stops the process at the point where the record failed.
Logging is the shipped behaviour and the right default while you are building. For anything that has to be complete, prefer failing loudly:
class TimestampingWriter {
handleError(error) {
console.error('writer error', error);
throw error;
}
}Step 4: Use it
The writer slots into the third constructor argument. Nothing else about the Machine changes.
const {
Machine,
MemoryRecorder,
MintblueReader,
} = require('@mintblue/sdk');
const { Consignments } = require('./consignments.js');
const { TimestampingWriter } = require('./timestamping-writer.js');
async function main() {
const sdkToken = process.env.MINTBLUE_SDK_TOKEN;
const projectId = process.env.MINTBLUE_PROJECT_ID;
const machine = new Machine(
Consignments,
await MintblueReader.create(sdkToken, projectId),
await TimestampingWriter.create(sdkToken, projectId),
new MemoryRecorder(),
{ emitAppEvents: true },
);
const app = machine.getInstance();
await app.recordArrival('batch-1234', 'Rotterdam');
machine.start();
}
main();Check it worked
Read the project's most recent transaction back and look at what your writer
produced. Two data outputs, the first carrying f and args, the second
carrying the timestamp.
const { Mintblue } = require('@mintblue/sdk');
const client = await Mintblue.create({ token: process.env.MINTBLUE_SDK_TOKEN });
const [latest] = await client.listTransactions({
project_id: process.env.MINTBLUE_PROJECT_ID,
order: 'desc',
limit: 1,
});
const full = await client.getTransaction({ txid: latest.txid, parse: true });
for (const output of full.outputs) {
if (output.type === 'data') console.log(output.value);
}Then confirm the Machine still works end to end: the recordArrival event fires
and app.state fills in. A writer that publishes something the reader cannot
turn back into a call is the failure this check catches, and it is the only one
worth worrying about.
Next
Build your first machine
Wire an application class, a reader, a writer and a recorder into a running Machine, and watch its state rebuild itself from the records it wrote.
Store snapshots in S3
Replace the Machine's in-memory recorder with one that persists snapshots to an S3 bucket, so a restart resumes instead of replaying everything.