@getanthill/datastore
Version:
Event-Sourced Datastore
771 lines (616 loc) • 17.9 kB
text/typescript
import type { ModelConfig, ModelSchema, Services } from '../typings';
import { Argument, Command, Option } from 'commander';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import merge from 'lodash/merge';
import omit from 'lodash/omit';
import pick from 'lodash/pick';
import * as utils from './utils';
import { REGEXP_DATE_ISO_STRING_8601 } from '../constants';
const validator = new Ajv({
useDefaults: false,
coerceTypes: true,
strict: false,
});
// @ts-ignore
addFormats(validator);
function addSchemaFields(
h: Command,
{
correlation_field,
schema,
}: { correlation_field: string; schema: ModelSchema },
) {
h.option(
`--${correlation_field} <${correlation_field}...>`,
'Correlation field',
);
h.option(`--json <json>`, 'JSON Query', JSON.parse);
for (const field in schema.model.properties) {
try {
const { type, description } = schema.model.properties[field];
if (type === 'array') {
h.option(`--${field} <${field}...>`, description);
} else {
h.option(`--${field} <${field}>`, description);
}
} catch (err) {
// console.error(err);
// ...
}
}
}
function buildQuery(config: ModelConfig, cmd: any, schema: any): any {
const { debug, dryRun, json, ...args } = cmd;
const query = merge(
pick(
args,
Object.keys({
[config.correlation_field]: {
type: 'string',
description: 'Correlation field',
},
...schema.properties,
}),
),
json,
);
const isValid = validator.validate(omit(schema, 'required'), query);
if (debug === true) {
utils.log({ dryRun, args, json, query, isValid }, cmd.format);
}
if (cmd.skipValidation === false && !isValid) {
utils.log(
{
err: 'Query is invalid',
dryRun,
args,
json,
query,
isValid,
details: validator.errors,
},
cmd.format,
);
throw new Error('Query is invalid');
}
return query;
}
export function find(services: Services, config: ModelConfig) {
const model = config.name;
return async (cmd: any) => {
try {
const datastore = services.datastores.get(config.datastore);
if (!datastore) {
return;
}
const { page, pageSize, ...args } = cmd;
const query = buildQuery(config, args, config.schema.model);
query._must_hash = cmd.skipHash === false;
if (cmd.fields) {
query._fields = cmd.fields.reduce(
(s: any, c: string) => ({
...s,
[c]: 1,
}),
{},
);
}
if (cmd.sort) {
query._sort = cmd.sort.reduce(
(s: any, c: string) => ({
[c.replace(/^[+-]/, '')]: c[0] === '-' ? -1 : 1,
}),
{},
);
}
if (cmd.dryRun === true) {
utils.log(query, cmd.format);
return;
}
let entities = [];
if (cmd.source === 'entities') {
const { data } = await datastore.find(model, query, page, pageSize, {
// @ts-ignore
'with-response-validation': `${cmd.skipValidation === false}`,
});
entities = data;
} else {
const { data } = await datastore.allEvents(
model,
query,
page,
pageSize,
{},
);
entities = data;
}
if (cmd.decrypt === true) {
const { data: decryptedData } = await datastore.decrypt(
model,
entities,
);
entities = decryptedData;
}
utils.log(entities, cmd.format);
} catch (err: any) {
if (err.response) {
utils.log(err.response.data, cmd.format);
return;
}
utils.log(err, cmd.format);
}
};
}
export function get(services: Services, config: ModelConfig) {
const model = config.name;
return async (correlationId: string, cmd: any) => {
try {
const datastore = services.datastores.get(config.datastore);
if (!datastore) {
return;
}
if (cmd.dryRun === true) {
utils.log(
{
[config.correlation_field]: correlationId,
},
cmd.format,
);
return;
}
let entity;
const { data: obj } = await datastore.get(model, correlationId);
entity = obj;
if (cmd.decrypt === true) {
const {
data: [decrypted],
} = await datastore.decrypt(model, [entity]);
entity = decrypted;
}
utils.log(entity, cmd.format);
} catch (err: any) {
if (err.response) {
utils.log(err.response.data, cmd.format);
return;
}
utils.log(err, cmd.format);
}
};
}
export function create(services: Services, config: ModelConfig) {
const model = config.name;
const schema = config.schema;
return async (cmd: any) => {
const v = Object.keys(schema.events.CREATED).sort().pop();
if (!v) {
return;
}
try {
const datastore = services.datastores.get(config.datastore);
if (!datastore) {
return;
}
const payload = buildQuery(config, cmd, schema.events.CREATED[v]);
if (cmd.dryRun === false) {
const { data } = await datastore.create(model, payload);
utils.log(data, cmd.format);
} else {
utils.log(payload, cmd.format);
}
} catch (err: any) {
if (err.response) {
utils.log(err.response.data, cmd.format);
return;
}
utils.log(err, cmd.format);
}
};
}
export function update(services: Services, config: ModelConfig) {
const model = config.name;
const schema = config.schema;
return async (correlationId: string, cmd: any) => {
const v = Object.keys(schema.events.CREATED).sort().pop();
if (!v) {
return;
}
try {
const datastore = services.datastores.get(config.datastore);
if (!datastore) {
return;
}
const payload = buildQuery(config, cmd, schema.events.UPDATED[v]);
if (cmd.dryRun === false) {
// @ts-ignore
const { data } = await datastore.update(model, correlationId, payload, {
upsert: cmd.upsert,
});
utils.log(data, cmd.format);
} else {
utils.log(payload, cmd.format);
}
} catch (err: any) {
if (err.response) {
utils.log(err.response.data, cmd.format);
return;
}
utils.log(err, cmd.format);
}
};
}
export function patch(services: Services, config: ModelConfig) {
const model = config.name;
return async (correlationId: string, _patches: string[], cmd: any) => {
try {
const datastore = services.datastores.get(config.datastore);
if (!datastore) {
return;
}
const patches = _patches.map((p) => JSON.parse(p));
if (cmd.dryRun === false) {
const { data } = await datastore.patch(model, correlationId, patches);
utils.log(data, cmd.format);
} else {
utils.log(patches, cmd.format);
}
} catch (err: any) {
if (err.response) {
utils.log(err.response.data, cmd.format);
return;
}
utils.log(err, cmd.format);
}
};
}
export function restore(services: Services, config: ModelConfig) {
const model = config.name;
return async (correlationId: string, version: number, cmd: any) => {
try {
const datastore = services.datastores.get(config.datastore);
if (!datastore) {
return;
}
if (cmd.dryRun === false) {
const { data } = await datastore.restore(model, correlationId, version);
utils.log(data, cmd.format);
} else {
utils.log({ correlation_id: correlationId, version }, cmd.format);
}
} catch (err: any) {
if (err.response) {
utils.log(err.response.data, cmd.format);
return;
}
utils.log(err, cmd.format);
}
};
}
export function count(services: Services, config: ModelConfig) {
const model = config.name;
return async (cmd: any) => {
try {
const datastore = services.datastores.get(config.datastore);
if (!datastore) {
return;
}
const { page, pageSize, ...args } = cmd;
const query = buildQuery(config, args, config.schema.model);
if (cmd.dryRun === false) {
const c = await datastore.count(
model,
{
...query,
_must_hash: cmd.skipHash === false,
},
cmd.source,
);
utils.log(c, cmd.format);
} else {
utils.log(query, cmd.format);
}
} catch (err: any) {
if (err.response) {
utils.log(err.response.data, cmd.format);
return;
}
utils.log(err, cmd.format);
}
};
}
export function events(services: Services, config: ModelConfig) {
const model = config.name;
return async (correlationId: string, cmd: any) => {
try {
const datastore = services.datastores.get(config.datastore);
if (!datastore) {
return;
}
const { page, pageSize } = cmd;
if (cmd.dryRun === true) {
utils.log(
{
[config.correlation_field]: correlationId,
},
cmd.format,
);
return;
}
let events = [];
const { data } = await datastore.events(
model,
correlationId,
page,
pageSize,
);
events = data;
if (cmd.decrypt === true) {
const { data: decryptedEvents } = await datastore.decrypt(
model,
events,
);
events = decryptedEvents;
}
utils.log(events, cmd.format);
} catch (err: any) {
if (err.response) {
utils.log(err.response.data, cmd.format);
return;
}
utils.log(err, cmd.format);
}
};
}
export function entity(services: Services, config: ModelConfig) {
const model = config.name;
return async (verb: string, correlationId: string, cmd: any) => {
try {
const datastore = services.datastores.get(config.datastore);
if (!datastore) {
return;
}
if (cmd.dryRun === true) {
utils.log(
{
[config.correlation_field]: correlationId,
},
cmd.format,
);
return;
}
let res;
if (verb === 'data') {
res = await datastore.data(model, correlationId, cmd.onlyModels);
} else {
/* @ts-ignore */
res = await datastore[verb](model, correlationId, true, cmd.onlyModels);
}
utils.log(res.data, cmd.format);
} catch (err: any) {
if (err.response) {
utils.log(err.response.data, cmd.format);
return;
}
utils.log(err, cmd.format);
}
};
}
export function updateMany(services: Services, config: ModelConfig) {
const model = config.name;
return async (query: string, update: string, cmd: any) => {
try {
const datastore = services.datastores.get(config.datastore);
const _update = JSON.parse(update);
let stats: any;
await datastore?.updateOverwhelmingly<string>(
model,
JSON.parse(query),
async () => _update,
(_stats: any) => {
stats = _stats;
Math.floor(stats.progress * 100) % 5 === 0 &&
utils.log(stats, cmd.format);
},
100,
);
} catch (err: any) {
if (err.response) {
utils.log(err.response.data, cmd.format);
return;
}
utils.log(err, cmd.format);
}
};
}
export function version(services: Services, config: ModelConfig) {
const model = config.name;
return async (correlationId: string, version: string, cmd: any) => {
try {
const datastore = services.datastores.get(config.datastore);
if (!datastore) {
return;
}
if (cmd.dryRun === true) {
utils.log(
{
[config.correlation_field]: correlationId,
},
cmd.format,
);
return;
}
let res;
const versionNumber = Number.parseInt(version, 10);
const isVersionISOString = REGEXP_DATE_ISO_STRING_8601.test(version);
if (isVersionISOString === true) {
res = await datastore.at(model, correlationId, version);
} else {
res = await datastore.version(model, correlationId, versionNumber);
}
utils.log(res.data, cmd.format);
} catch (err: any) {
if (err.response) {
utils.log(err.response.data, cmd.format);
return;
}
utils.log(err, cmd.format);
}
};
}
export default function register(services: Services, config: ModelConfig) {
const program = new Command(config.name);
const summary = config.description?.split('\n', 2)[0] ?? '';
program
.summary(
`${config.datastore}@${config.name} (v${config.version})${
summary ? ' - ' + summary : ''
}`,
)
.description(
`datastore:\t${config.datastore}
version:\t${config.version}
created_at:\t${config.created_at}
updated_at:\t${config.updated_at}
description:
${(config.description || config.name || '').split('\n').shift()}
`,
);
let c = program
.command('schema')
.description(`Returns the JSON schema associated to ${config.name}`)
.action(() => {
utils.log(config.schema.model);
});
// Find
c = program
.command(`find`)
.description(`Get available entities for ${config.name}`);
addSchemaFields(c, config);
utils.addStandardFields(c);
try {
c.addOption(
new Option('-s, --source <source>', 'Count source: entities or events')
.default('entities')
.choices(['entities', 'events']),
);
} catch (err) {
// console.error(err);
}
c.option('--fields <fields...>', 'Fields to return from the entity');
c.option('--sort <sorts...>', 'Sort entities on some specific fields');
c.option('--decrypt', 'Must try to decrypt entities', false);
c.option('--skip-hash', 'Remove the hash request', false);
c.option('--skip-validation', 'Skip query validation', false);
utils.addPaginationFields(c);
c.action(find(services, config));
// Get
c = program
.command(`get`)
.argument('<correlation_id>', 'Entity correlation ID')
.description(`Get one entity for ${config.name}`);
addSchemaFields(c, config);
utils.addStandardFields(c);
c.option('--decrypt', 'Must try to decrypt entities').action(
get(services, config),
);
// Create
c = program
.command(`create`)
.description(`Create a new entity for ${config.name}`);
addSchemaFields(c, config);
utils.addStandardFields(c);
c.option('--skip-validation', 'Skip query validation', false);
c.action(create(services, config));
// Update
c = program
.command(`update`)
.argument('<correlation_id>', 'Entity correlation ID')
.description(`Update a single entity for ${config.name}`);
c.option('--upsert', 'Upsert the entity if not exists', false);
addSchemaFields(c, config);
utils.addStandardFields(c);
c.option('--skip-validation', 'Skip query validation', false);
c.action(update(services, config));
// UpdateMany
c = program
.command(`update:many`)
.argument('<query>', 'Matching query')
.argument('<update>', 'Update query')
.description(`Update all entities for ${config.name} matching the query`);
c.action(updateMany(services, config));
// Patch
c = program
.command(`patch`)
.argument('<correlation_id>', 'Entity correlation ID')
.argument('<patches...>', 'JSON Patch')
.description(`Patch a single entity for ${config.name}`);
utils.addStandardFields(c);
c.action(patch(services, config));
// Restore
c = program
.command(`restore`)
.argument('<correlation_id>', 'Entity correlation ID')
.argument('<version>', 'Entity version to restore')
.description(
`Restore a single entity for ${config.name} to a given version`,
);
utils.addStandardFields(c);
c.action(restore(services, config));
// Count
c = program
.command(`count`)
.description(
`Get count of entities for ${config.name} matching your query`,
);
addSchemaFields(c, config);
utils.addStandardFields(c);
try {
c.addOption(
new Option('-s, --source <source>', 'Count source: entities or events')
.default('entities')
.choices(['entities', 'events']),
);
} catch (err) {
// console.error(err);
}
c.option('--skip-hash', 'Remove the hash request', true);
c.option('--skip-validation', 'Skip query validation', false);
c.action(count(services, config));
// Events
c = program
.command(`events`)
.argument('<correlation_id>', 'Entity correlation ID')
.description(`Get events associated to a ${config.name}`);
utils.addPaginationFields(c);
utils.addStandardFields(c);
c.option('--decrypt', 'Must try to decrypt entities').action(
events(services, config),
);
// Entity
c = program
.command('entity')
.addArgument(
new Argument('<verb>', 'Verb to apply on the entity').choices([
'data',
'archive',
'unarchive',
'delete',
]),
)
.argument('<correlation_id>', 'Entity correlation ID')
.description(
`Apply a specific method to data related to a single entity for ${config.name}`,
);
c.option('--only-models <models...>', 'Models to retrieve data from');
utils.addStandardFields(c);
c.action(entity(services, config));
// Version
c = program
.command('version')
.argument('<correlation_id>', 'Entity correlation ID')
.addArgument(new Argument('<version>', 'Version'))
.description(`Get entity at version`);
utils.addStandardFields(c);
c.action(version(services, config));
return program;
}