Create a Model
Octo's models carry almost nothing. A Region holds a region ID and
that is all; a Service holds a service ID, the nodes it sits under, and a type and a label, and
nothing about what the service actually is. That is deliberate. The base models fix the vocabulary,
and what each word means in your company is yours to decide.
Your model is a subclass. This page writes ShopQueueService, the node that appears in the shop's
intent when the order queue exists.
octo create-model -n shop-queue-service -t service --package @shop
The -n is the name of the module the model belongs to, and -t is the base model it extends. The
files land under that module, in models/service/.
The serialized shape
A model is written to .octo/models.json and read back on the next run, and the schema is what
crosses that boundary.
import { Schema, ServiceSchema, Validate } from '@quadnix/octo';
export class ShopQueueServiceSchema extends ServiceSchema {
@Validate({ options: { minLength: 1 } })
queueName = Schema<string>();
}
Extend the base model's schema, and add only what your model holds beyond it. ServiceSchema
already carries serviceId, so the queue adds one field.
Keep this small. Every field here is state you are promising to be able to read back, and a field you add today is a field that has to keep deserializing for as long as anybody has state containing it.
The model
import { type AModel, Model, Service, ServiceType } from '@quadnix/octo';
import { ShopQueueServiceSchema } from './shop-queue-service.schema.js';
@Model<ShopQueueService>('@shop', 'service', ShopQueueServiceSchema)
export class ShopQueueService extends Service {
readonly queueName: string;
constructor (queueName: string, parents: AModel<any, any>[]) {
super(`${queueName}-queue`, parents, ServiceType.OTHER, 'sqs');
this.queueName = queueName;
}
static override async unSynth (
service: ShopQueueServiceSchema,
deReferenceContext: (context: string) => Promise<AModel<any, any>>,
): Promise<ShopQueueService> {
const parents = await Promise.all(service.parents.map((p) => deReferenceContext(p.context)));
return new ShopQueueService(service.queueName, parents);
}
override synth (): ShopQueueServiceSchema {
return {
...super.synth(),
queueName: this.queueName,
};
}
}
The decorator takes three things: your package name, the node name, and the schema. The node name is
'service', the same as the base model, and it has to be. That string is what everything else
matches on, so an AwsS3StorageService and a ShopQueueService are both service to a module that
wants one. Any string but a base model's name does not compile.
synth() and unSynth() are the two halves of serialization, and the rule is that
unSynth(synth(x)) has to give you back something equivalent to x. super.synth() writes what
every service carries: its ID, its type and label, and the context of each parent. unSynth() gets
each parent back from its context through deReferenceContext. The constructor derives the service
ID from the queue name, so shop-orders under the app becomes service=shop-orders-queue,app=shop,
and that is the context the reports print.
Nothing here knows what SQS is. The model says a queue exists and what it is called; what that means in AWS is the action's business.
Deriving the ID
The one decision on this page with lasting consequences. A model's context is derived from its ID and its position in the graph, and Octo uses that context as identity everywhere.
super(`${queueName}-queue`, parents, ServiceType.OTHER, 'sqs');
Derive it from an input, so that two instances of the module do not collide. Derive it from an input that does not move, because changing it means Octo sees one model gone and another arrived, and for a queue that means the messages in it. A name is a good source; a timestamp, a hash of the whole configuration, or anything looked up from the cloud is not.
Behaviour on the model
A model can do more than hold fields. Adding a method that maintains an invariant is often better than making the module do it, because the model is the thing that gets deserialized and the module is not. Our S3 storage model does this for access prefixes:
addAccessPrefix(accessPrefix: string): void {
if (this.accessPrefixes.find((d) => d.accessPrefix === accessPrefix)) {
throw new ModelError('Access prefix already added in S3 bucket!', this);
}
const accessPrefixHash = StringUtility.deterministicHash(accessPrefix).substring(0, 12);
const accessPrefixAnchorName = `AwsS3StorageServiceAccessPrefixAnchor-${accessPrefixHash}`;
this.addAnchor(
new AwsS3StorageServiceAccessPrefixAnchor(
accessPrefixAnchorName,
{ accessPrefix, bucketName: this.bucketName },
this,
),
);
this.accessPrefixes.push({ accessPrefix, accessPrefixAnchorName });
}
One call, and the model stays consistent: the list and the anchor cannot disagree, and adding the same prefix twice is an error rather than a silent duplicate.
Where it attaches
A model has to be attached to the graph, and how is fixed by the base model you extended. A region is added to an account, and a subnet to a region. A service is given its parents when it is built, and they can be any nodes: the queue sits under the app, a bucket under its region, and a load balancer under both of its subnets.
const queueService = new ShopQueueService(inputs.queueName, [app]);
Get this from the constructor, not by constructing edges by hand. It creates the dependency to each parent in both directions, with the right ordering rules already on it. A service's parents are fixed for its lifetime, because its context is built from theirs.