@getanthill/datastore
Version:
Event-Sourced Datastore
542 lines (453 loc) • 13.3 kB
text/typescript
import type {
HandlerConfig,
ModelConfig,
RunnerServices,
RunnerTrigger,
} from '../../typings';
import type Datastore from '../Datastore';
import { ok } from 'node:assert';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import Ajv from 'ajv';
import * as telemetry from '@getanthill/telemetry';
import Aggregator from '../aggregator/Aggregator';
import thingsModelConfig from '../../templates/examples/things.json';
import PostgreSQLClient from '../../services/pg';
import { build } from '../../services';
export * from './utils';
import schemas from '../schemas.json';
const validator = new Ajv({
schemas,
useDefaults: false,
coerceTypes: true,
strict: false,
});
function validate(schemaPath: string, data: unknown) {
const isValid = validator.validate(schemaPath, data);
if (isValid === false) {
const err = new Error('Validation failed');
// @ts-ignore
err.details = [
{
path: schemaPath,
},
{
data,
},
// @ts-ignore
...validator.errors,
];
throw err;
}
}
export async function getProjectionConfiguration(
url: URL,
datastores: Map<string, Datastore>,
) {
const names = Array.from(datastores.keys());
const projectionSource = url.searchParams.get('source') ?? names[0];
const projectionEntityType =
url.searchParams.get('entity_type') ?? 'projections';
const projectionConfigurationPath = url.searchParams.get('path') ?? null;
const configurationPath = url.searchParams.get('configuration_path') ?? null;
const projectionField = url.searchParams.get('projection_field');
const projectionId = url.searchParams.get('projection_id');
let configuration = null;
if (configurationPath) {
configuration = require(configurationPath);
} else if (!projectionField && projectionId) {
const res: any = await datastores
.get(projectionSource)
?.get(projectionEntityType, projectionId);
configuration = res.data;
} else if (projectionField && projectionId) {
const res: any = await datastores
.get(projectionSource)
?.find(projectionEntityType, {
[projectionField]: projectionId,
});
configuration = res.data[0];
}
if (projectionConfigurationPath) {
configuration = get(configuration, projectionConfigurationPath, null);
}
ok(configuration, 'Projection configuration not found');
validate('/schemas/datastore/projection', configuration);
return configuration;
}
export function getDestinationDatastore(
url: URL,
datastores: Map<string, Datastore>,
configuration: any,
) {
const names = Array.from(datastores.keys());
const projectionDestination =
url.searchParams.get('destination') ||
configuration?.destination ||
names[names.length - 1];
const destination = datastores.get(projectionDestination)!;
return destination;
}
export async function initDestinationModel(
url: URL,
datastores: Map<string, Datastore>,
configuration: any,
) {
const destination = getDestinationDatastore(url, datastores, configuration);
ok(!!configuration.name, 'Missing Datastores configuration name');
const projectionModelConfig: ModelConfig = {
...thingsModelConfig,
...configuration.model,
name: configuration.name,
};
ok(
!!projectionModelConfig.correlation_field,
'Missing projection correlation_field',
);
try {
await destination.createModel(projectionModelConfig);
} catch (err: any) {
if (err.response && err.response.status === 409) {
await destination.updateModel(projectionModelConfig);
} else {
throw err;
}
}
await destination.createModelIndexes(projectionModelConfig);
}
export async function getTriggers(
url: URL,
datastores: Map<string, Datastore>,
aggregator: Aggregator,
configuration: any,
) {
const destination = getDestinationDatastore(url, datastores, configuration);
// Trigger configuration
const triggers: RunnerTrigger[] = (
configuration.triggers ?? [
{
...configuration.from,
...configuration.trigger,
},
]
).map((trigger: any) => ({
...trigger,
query: aggregator.applyMap(trigger.query, trigger.map, {}),
}));
// Increment feature
const isIncremental: boolean =
(url.searchParams.get('is_incremental') ?? 'false') === 'true';
if (isIncremental === true) {
const incrementalField: string =
url.searchParams.get('incremental_field') ?? 'updated_at';
const incrementalSourceField: string =
url.searchParams.get('incremental_source_field') ?? incrementalField;
const {
data: [lastDocument],
}: { data: [any] } = await destination.find(
configuration.name,
{
_sort: { [incrementalField]: -1 },
_fields: {
[incrementalField]: 1,
},
},
0,
1,
);
if (lastDocument) {
triggers.forEach((trigger: { [key: string]: any }) => {
trigger.query[incrementalSourceField] = {
'date($gt)': lastDocument?.[incrementalField],
};
});
}
}
triggers.forEach((trigger) =>
validate('/schemas/datastore/runner/trigger', trigger),
);
return triggers;
}
export const main = async function (
url: URL,
/* istanbul ignore next */
services = build(),
): Promise<HandlerConfig> {
const datastores = services.datastores;
const heartbeat = url.searchParams.get('heartbeat') ?? 'true';
const progress = Number.parseInt(
url.searchParams.get('progress') ?? '1000',
10,
);
const withInit = url.searchParams.get('init') === 'true';
// Checking the Datastore configurations.
await Promise.all(
Array.from(services.datastores.values()).map(
(ds) => heartbeat !== 'false' && ds.heartbeat(),
),
);
const aggregator = new Aggregator(services.datastores);
const configuration = await getProjectionConfiguration(url, datastores);
if (withInit === true) {
await initDestinationModel(url, datastores, configuration);
}
const triggers = await getTriggers(
url,
datastores,
aggregator,
configuration,
);
const counts: number[] = [];
for (const trigger of triggers) {
const count =
(await datastores
.get(trigger.datastore!)
?.count(trigger.model!, trigger.query!, trigger.source)) ?? 0;
counts.push(count);
}
// Stats
const stats = {
count: 0,
total: counts.reduce((s, c) => s + c, 0),
processed: 0,
failed: 0,
skipped: 0,
};
telemetry.logger.debug('[projections] Initialization', {
configuration,
});
telemetry.logger.info('[projections] Initialization', {
datastores_count: datastores.size,
name: configuration.name,
datastores_names: Array.from(datastores.keys()),
from: configuration.from,
triggers,
});
return {
triggers,
start: async () => ({
datastores: Object.fromEntries(datastores),
}),
stop: async () => {
return;
},
handler: async (
entity: any,
metadata: {
handlerId?: string;
path?: string;
datastore?: string;
model?: string;
source?: string;
raw?: boolean;
},
): Promise<any> => {
stats.count += 1;
telemetry.logger.debug('[projections] Projecting entity...', {
entity,
name: configuration.name,
metadata,
});
const _aggregator = new Aggregator(datastores, configuration.aggregator);
let data: any = null;
try {
data = await _aggregator.aggregate(configuration.pipeline, {
...configuration.state,
metadata,
entity,
});
stats.processed += 1;
_aggregator.logs.forEach((l) =>
/* @ts-ignore */
telemetry.logger[l.level](l.msg, l.context),
);
telemetry.logger.debug('[projections] Entity successfully projected', {
entity,
name: configuration.name,
data,
});
} catch (err: any) {
telemetry.logger.debug('[projections] Projection error - logs', {
logs: _aggregator.logs,
});
if (err.message === Aggregator.ERROR_VALIDATE_STEP_FAILED.message) {
stats.skipped += 1;
telemetry.logger.debug(
'[projections] Skipped entity on validation failed step',
{
entity,
name: configuration.name,
err,
},
);
} else if (err === Aggregator.ERROR_ENTITY_NOT_FOUND) {
stats.skipped += 1;
telemetry.logger.debug(
'[projections] Skipped entity on fetch step as entity not found',
{
entity,
name: configuration.name,
err,
},
);
} else {
stats.failed += 1;
telemetry.logger.error('[projections] Projection error', {
entity,
name: configuration.name,
details: err?.response?.data,
err,
});
throw err;
}
}
stats.count % progress === 0 &&
telemetry.logger.info('[projections] Stats', {
name: configuration.name,
...stats,
progress: stats.count / stats.total,
});
return { stats, data };
},
};
};
export const syncPostgreSQL = async function (
url: URL,
services = build(),
): Promise<HandlerConfig> {
const datastoreName = url.searchParams.get('datastore') ?? 'datastore';
const source = (url.searchParams.get('source') ?? 'events') as
| 'events'
| 'entities';
const query = JSON.parse(url.searchParams.get('query') ?? '{}');
const skip = (url.searchParams.get('skip') ?? '')
.split(',')
.map((v) => v.trim())
.filter((v) => !!v);
const only = (url.searchParams.get('only') ?? '')
.split(',')
.map((v) => v.trim())
.filter((v) => !!v);
const withEncryptedData =
url.searchParams.get('with_encrypted_data') === 'true';
// Overwriting the pg namespace:
const pgConfig = cloneDeep({
...services.config.pg,
namespace: url.searchParams.get('ns') ?? services.config.pg.namespace,
});
const pg = new PostgreSQLClient(pgConfig);
const sdk = services.datastores.get(datastoreName);
if (!sdk) {
throw new Error('Unknown datastore');
}
// @ts-ignore
services.pg = pg;
const { data: models } = await sdk.getModels();
const triggers: RunnerTrigger[] = Object.keys(models)
.filter((m) => {
if (only.length > 0 && !only.includes(m)) {
return false;
}
if (skip.length > 0 && skip.includes(m)) {
return false;
}
return true;
})
.map((model) => ({
datastore: datastoreName,
model,
source,
query,
}));
const counts: number[] = [];
for (const trigger of triggers) {
const count = await sdk.count(
trigger.model!,
trigger.query!,
trigger.source,
);
counts.push(count);
}
services.telemetry.logger.info(
'[projections#syncPostgreSQL] Counts',
triggers.reduce((s, c, i) => {
s[c.model!] = counts[i];
return s;
}, {} as any),
);
const stats = {
count: 0,
total: counts.reduce((s, c) => s + c, 0),
success: 0,
error: 0,
};
return {
triggers,
start: async () => {
services.telemetry.logger.info('[projections#syncPostgreSQL] Starting', {
datastore: datastoreName,
source,
query,
});
services.telemetry.logger.debug(
'[projections#syncPostgreSQL] Connecting to PostgreSQL',
);
await pg.connect();
if (url.searchParams.get('init') === '1') {
services.telemetry.logger.debug(
'[projections#syncPostgreSQL] Database creation if not exists',
);
await PostgreSQLClient.init(services.config.pg);
services.telemetry.logger.debug(
'[projections#syncPostgreSQL] Database schema initialization',
);
await pg.queryAll(
PostgreSQLClient.getSqlSchemaForModels(
Object.values(models),
url.searchParams.get('clean') === '1',
pgConfig.namespace,
),
);
}
return services as unknown as RunnerServices;
},
stop: async () => {
services.telemetry.logger.info(
'[projections#syncPostgreSQL] Ending',
stats,
);
await pg.disconnect();
},
handler: async (event, query) => {
stats.count % 1000 === 0 &&
services.telemetry.logger.info(
'[projections#syncPostgreSQL] Syncing...',
{
...stats,
progress: stats.count / stats.total,
},
);
try {
stats.count += 1;
services.telemetry.logger.debug(
'[projections#syncPostgreSQL] New event',
{
event,
query,
stats,
},
);
await pg.insert(models[query.model!], query.source!, event, {
with_encrypted_data: withEncryptedData,
});
stats.success += 1;
} catch (err) {
stats.error += 1;
services.telemetry.logger.error(
'[projections#syncPostgreSQL] Error',
err,
);
}
},
};
};