@getanthill/datastore
Version:
Event-Sourced Datastore
453 lines (368 loc) • 10.6 kB
text/typescript
import type { Ctx } from '../../typings';
import path from 'path';
import * as telemetry from '@getanthill/telemetry';
import { Command } from 'commander';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import merge from 'lodash/merge';
import setup from '../../setup';
import services from '../../services';
import * as runner from '../runner';
import * as utils from '../../utils';
import thingsModelConfig from '../../templates/examples/things.json';
import expect from 'expect';
const CLI_VERSION = '0.2.0';
const program = new Command();
program
.storeOptionsAsProperties(false)
.version(CLI_VERSION, '-v, --version', 'output the current version');
function mapEntityValues(
ctx: Ctx,
entity: any,
iteration: number,
cloneIndex: number,
) {
const clonedEntity = cloneDeep(entity);
utils.mapValuesDeep(clonedEntity, (v: any) => {
if (typeof v !== 'string') {
return v;
}
// Entity mapping
const [id, ...fragments] = v.slice(1, -1).split('.');
if (ctx.entities!.has(id)) {
return get(ctx.entities!.get(id), fragments);
}
return v
.replace(/\{(i|iteration)\}/g, iteration.toString())
.replace(/\{(c|clone_index)\}/g, cloneIndex.toString())
.replace(/\{(i|iteration)-1\}/g, `${Math.max(0, iteration - 1)}`)
.replace(/\{uuid\}/g, setup.uuid());
});
return clonedEntity;
}
function mockDate(str?: string) {
if (str === undefined) {
return;
}
const constantDate = new Date(str);
// @ts-ignore
global._Date = Date;
// @ts-ignore
global.Date = class extends Date {
constructor() {
super();
return constantDate;
}
static now() {
return constantDate.getTime();
}
};
}
function unmockDate() {
/* @ts-ignore */
if (global._Date === undefined) {
return;
}
/* @ts-ignore */
global.Date = global._Date;
/* @ts-ignore */
delete global._Date;
}
export async function buildContext(initialContext: any) {
let ctx = {
telemetry,
...initialContext,
};
ctx = {
...ctx,
...(await prepare(ctx)),
};
ctx.projections = await init(ctx);
await sendEvents(ctx, ctx.data!.imports, 0);
return ctx;
}
export async function getHandler(ctx: Ctx, handlerType: 'start' | 'replay') {
/* @ts-ignore */
const handler = runner[handlerType]();
return handler(
ctx.projections!.map(
(projection) =>
`/projections?projection_id=${projection.projection_id}&${
ctx.data!.config.runner_params || ''
}`,
),
{
exitTimeout: 100,
verbose: true,
cwd: path.resolve(__dirname, '..'),
maxReconnectionAttempts: 1000,
reconnectionInterval: 100,
connectionMaxLifeSpanInSeconds: 3600,
pageSize: 10,
},
{},
);
}
export async function load(dir: string) {
return require(dir);
}
export async function prepare(ctx: Ctx) {
ctx.telemetry.logger.info('[validator] Preparing datastores...');
const instances = new Map();
const entities = new Map();
const modelConfigs = new Map();
const datastores = Object.keys(ctx.data!.config.datastores);
services.datastores = new Map();
for (const d of datastores) {
ctx.telemetry.logger.debug('[validator] Starting datastore...', {
datastore: d,
});
const instance = await setup.startApi(
merge(
{
security: {
tokens: [
{
id: 'admin',
level: 'admin',
token: 'token',
},
],
},
features: {
api: {
admin: true,
updateSpecOnModelsChange: true,
},
cache: {
isEnabled: false,
},
},
},
ctx.data!.config.datastores[d].config,
),
);
instances.set(d, instance);
services.datastores.set(d, instance[5]);
const _modelConfigs = ctx.data!.config.datastores[d].modelConfigs || [];
for (const modelConfig of _modelConfigs) {
ctx.telemetry.logger.debug('[validator] Importing model...', {
datastore: d,
model: modelConfig.name,
});
await instance[5].createModel(modelConfig);
}
await instance[7].restart();
for (const modelConfig of _modelConfigs) {
ctx.telemetry.logger.debug('[validator] Creating model indexes...', {
datastore: d,
model: modelConfig.name,
});
await instance[5].createModelIndexes(modelConfig);
}
const { data: initializedModelConfigs } = await instance[5].getModels();
ctx.telemetry.logger.debug('[validator] Available models...', {
datastore: d,
models: Object.keys(initializedModelConfigs),
});
modelConfigs.set(d, initializedModelConfigs);
}
return {
instances,
entities,
modelConfigs,
datastores,
};
}
export async function init(ctx: Ctx) {
ctx.telemetry.logger.info('[validator] Initializing projections...');
const sourceInstance = ctx.instances.get(
ctx.data!.projections[0]?.from?.datastore ||
ctx.data!.projections[0]?.triggers[0].datastore,
);
try {
sourceInstance[5].config.debug = true;
const { data: ddd } = await sourceInstance[5].createModel({
...thingsModelConfig,
db: 'datastore',
name: 'projections',
correlation_field: 'projection_id',
schema: {
...thingsModelConfig.schema,
model: {
additionalProperties: true,
properties: {},
},
},
});
await sourceInstance[7].restart();
} catch (err: any) {
if (err?.response?.data?.status !== 409) {
throw err;
}
// Model already initialized
}
const projections = [];
for (const projection of ctx.data!.projections) {
const { data: _projection } = await sourceInstance[5].create(
'projections',
projection,
);
projections.push(_projection);
}
return projections;
}
export async function sendEvents(ctx: Ctx, events: any[], waitTimeout = 3000) {
for (const event of events) {
const sdk = ctx.instances.get(event.datastore)[5];
const modelConfigs = ctx.modelConfigs.get(event.datastore);
const repeat: number = event.repeat || 1;
const clone: number = event.clone || 1;
const sleep: number = event.sleep || 100;
const entities: any[] = cloneDeep(event.entities || []);
for (let i = 1; i < clone; i++) {
entities.push(...event.entities);
}
ctx.telemetry.logger.info('[validator] Sending events...', {
entities_count: entities.length,
repeat,
clone,
sleep,
});
mockDate(event.date ?? ctx.data!.config.imports?.date);
for (let iteration = 0; iteration < repeat; iteration++) {
const _entities = entities.map((entity, cloneIndex) => ({
...entity,
idempotency: mapEntityValues(
ctx,
entity.idempotency,
iteration,
cloneIndex,
),
entity: mapEntityValues(ctx, entity.entity, iteration, cloneIndex),
...mapEntityValues(
ctx,
{
id: entity.id,
},
iteration,
cloneIndex,
),
}));
await sdk.import(
_entities,
modelConfigs,
{
dryRun: false,
},
ctx.entities,
);
await new Promise((resolve) => setTimeout(resolve, sleep));
}
unmockDate();
}
await new Promise((resolve) => setTimeout(resolve, waitTimeout));
}
export async function run(ctx: Ctx, handlerType: 'start' | 'replay') {
handlerType === 'replay' && (await sendEvents(ctx, ctx.data!.events, 0));
await getHandler(ctx, handlerType);
handlerType === 'start' &&
(await sendEvents(ctx, ctx.data!.events, ctx.data!.config.exit_timeout));
}
export async function assert(ctx: Ctx, handlerType: string) {
let withErrors = false;
ctx.telemetry.logger.info('[validator] Checking results...');
const assertions = ctx.data!.assertions.filter(
(assertion) =>
assertion.handler === undefined || assertion.handler === handlerType,
);
for (const assertion of assertions) {
if (assertion.entities.length === 0) {
continue;
}
const res = await ctx.instances
.get(assertion.datastore)[5]
.find(assertion.model, assertion.query || {});
try {
if (assertion.log === true) {
ctx.telemetry.logger.info('[validator] Assertion', { data: res.data });
}
expect(res.data).toEqual(
expect.arrayContaining([
expect.objectContaining(
mapEntityValues(ctx, assertion.entities[0], 0, 0),
),
]),
);
} catch (err: any) {
console.error(err);
withErrors = true;
ctx.telemetry.logger.error('[validator] ❌ Assertion failure', {
err,
data: res.data,
assertion,
details: err?.response?.data,
});
}
}
if (withErrors === true) {
throw new Error('Tests failed');
}
ctx.telemetry.logger.info('[validator] ✅ All checks passed');
}
export async function tearDown(ctx: Ctx) {
ctx.telemetry.logger.info('[validator] Stopping...');
for (const ds of services.datastores.values()) {
ds.closeAll();
}
for (const d of ctx.datastores || []) {
const instance = ctx.instances.get(d);
ctx.telemetry.logger.debug('[validator] Dropping database...', {
datastore: d,
});
await setup.teardownDb(instance[1]);
ctx.telemetry.logger.debug('[validator] Stopping datastore...', {
datastore: d,
});
await setup.stopApi(instance[7]);
}
}
async function validator(
dir: string,
handlerType: 'start' | 'replay',
cmd: any,
) {
let ctx: Ctx = {
telemetry,
};
try {
const data = await load(dir);
ctx = await buildContext({ data });
await run(ctx, handlerType);
await assert(ctx, handlerType);
} catch (err: any) {
if (err?.response?.data) {
console.error(err?.response?.data);
} else {
console.error(err);
}
} finally {
await tearDown(ctx);
}
}
export async function cli(argv = process.argv) {
program
.argument('<path>', 'Path of the projections entries')
.argument('[handler_type]', 'Handler type: start, replay', 'start')
.option(
'-v, --verbosity <verbosity>',
'Logger level verbosity. 0: ALL 100: NOTHING]',
'50',
)
.option('-p, --projections <projections...>', 'List of path projections')
.action(validator);
return program.parse(argv);
}
if (!module.parent) {
cli();
}