@miso.ai/server-sdk 0.6.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/delete.js ADDED
@@ -0,0 +1,85 @@
1
+ import split2 from 'split2';
2
+ import { log, stream } from '@miso.ai/server-commons';
3
+ import { MisoClient, logger } from '../src/index.js';
4
+
5
+ function build(yargs) {
6
+ return yargs
7
+ .option('records-per-request', {
8
+ alias: ['rpr'],
9
+ describe: 'How many records to send in a request',
10
+ })
11
+ .option('records-per-second', {
12
+ alias: ['rps'],
13
+ describe: 'How many records to send per second',
14
+ })
15
+ .option('debug', {
16
+ describe: 'Set log level to debug',
17
+ type: 'boolean',
18
+ })
19
+ .option('progress', {
20
+ alias: ['p'],
21
+ describe: 'Set log format progress',
22
+ type: 'boolean',
23
+ })
24
+ .option('stream-name', {
25
+ alias: ['name'],
26
+ describe: 'Stream name that shows up in log messages',
27
+ })
28
+ .option('log-level', {
29
+ describe: 'Log level',
30
+ })
31
+ .option('log-format', {
32
+ describe: 'Log format',
33
+ });
34
+ }
35
+
36
+ const run = type => async ({
37
+ key,
38
+ server,
39
+ param: params,
40
+ ['records-per-request']: recordsPerRequest,
41
+ ['records-per-second']: recordsPerSecond,
42
+ debug,
43
+ progress,
44
+ ['stream-name']: name,
45
+ ['log-level']: loglevel,
46
+ ['log-format']: logFormat,
47
+ }) => {
48
+
49
+ loglevel = (debug || progress) ? log.DEBUG : loglevel;
50
+ logFormat = progress ? logger.FORMAT.PROGRESS : logFormat;
51
+
52
+ const client = new MisoClient({ key, server });
53
+
54
+ const deleteStream = client.createDeleteStream(type, {
55
+ name,
56
+ params,
57
+ heartbeatInterval: logFormat === logger.FORMAT.PROGRESS ? 250 : false,
58
+ recordsPerRequest,
59
+ recordsPerSecond,
60
+ });
61
+
62
+ const logStream = logger.createLogStream({
63
+ api: 'delete',
64
+ type,
65
+ level: loglevel,
66
+ format: logFormat,
67
+ });
68
+
69
+ await stream.pipeline(
70
+ process.stdin,
71
+ split2(),
72
+ deleteStream,
73
+ logStream,
74
+ );
75
+ };
76
+
77
+ export default function(type) {
78
+ return {
79
+ command: 'delete',
80
+ aliases: ['d'],
81
+ description: `Delete ${type}`,
82
+ builder: build,
83
+ handler: run(type),
84
+ };
85
+ }
package/cli/ids.js ADDED
@@ -0,0 +1,32 @@
1
+ import { Readable } from 'stream';
2
+ import { stream } from '@miso.ai/server-commons';
3
+ import { MisoClient } from '../src/index.js';
4
+
5
+ function build(yargs) {
6
+ return yargs;
7
+ }
8
+
9
+ const run = type => async ({
10
+ key,
11
+ server,
12
+ }) => {
13
+ const client = new MisoClient({ key, server });
14
+ const ids = await client.ids(type);
15
+
16
+ const readStream = Readable.from(ids);
17
+ const outputStream = new stream.OutputStream();
18
+
19
+ await stream.pipeline(
20
+ readStream,
21
+ outputStream,
22
+ );
23
+ };
24
+
25
+ export default function(type) {
26
+ return {
27
+ command: 'ids',
28
+ description: false,
29
+ builder: build,
30
+ handler: run(type),
31
+ };
32
+ }
package/cli/index.js ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ import 'dotenv/config';
3
+ import yargs from 'yargs/yargs';
4
+ import { hideBin } from 'yargs/helpers';
5
+ import upload from './upload.js';
6
+ import _delete from './delete.js';
7
+ import ids from './ids.js';
8
+ import version from '../src/version.js';
9
+
10
+ const interactions = {
11
+ command: 'interactions',
12
+ aliases: ['interaction', 'i'],
13
+ description: 'Interaction commands',
14
+ builder: yargs => _buildBase(yargs)
15
+ .command(upload('interactions')),
16
+ };
17
+
18
+ const products = {
19
+ command: 'products',
20
+ aliases: ['product', 'p'],
21
+ description: 'Product commands',
22
+ builder: yargs => _buildBase(yargs)
23
+ .command(upload('products'))
24
+ .command(_delete('products'))
25
+ .command(ids('products')),
26
+ };
27
+
28
+ const users = {
29
+ command: 'users',
30
+ aliases: ['user', 'u'],
31
+ description: 'User commands',
32
+ builder: yargs => _buildBase(yargs)
33
+ .command(upload('users'))
34
+ .command(_delete('users'))
35
+ .command(ids('users')),
36
+ };
37
+
38
+ const experiments = {
39
+ command: 'experiments',
40
+ aliases: ['experiment'],
41
+ description: 'Experiment commands',
42
+ builder: yargs => _buildBase(yargs)
43
+ .option('experiment-id', {
44
+ alias: ['exp-id'],
45
+ describe: 'Experiment ID for experiment API',
46
+ })
47
+ .command({
48
+ command: 'events',
49
+ builder: yargs => yargs
50
+ .command(upload('experiment-events')),
51
+ }),
52
+ };
53
+
54
+ yargs(hideBin(process.argv))
55
+ .env('MISO')
56
+ .command(interactions)
57
+ .command(products)
58
+ .command(users)
59
+ .command(experiments)
60
+ .demandCommand(2)
61
+ .version(version)
62
+ .help()
63
+ .fail(_handleFail)
64
+ .parse();
65
+
66
+
67
+
68
+ // helpers //
69
+ function _buildBase(yargs) {
70
+ return yargs
71
+ .option('key', {
72
+ alias: ['k', 'api-key'],
73
+ describe: 'API key',
74
+ })
75
+ .option('server', {
76
+ alias: ['api-server'],
77
+ describe: 'API server',
78
+ })
79
+ .option('param', {
80
+ alias: ['v', 'var'],
81
+ describe: 'Extra URL parameters',
82
+ type: 'array',
83
+ coerce: _coerceToArray,
84
+ })
85
+ .demandOption(['key'], 'API key is required.');
86
+ }
87
+
88
+ function _coerceToArray(arg) {
89
+ return Array.isArray(arg) ? arg :
90
+ typeof arg === 'string' ? arg.split(',') :
91
+ arg === undefined || arg === null ? [] : [arg];
92
+ }
93
+
94
+ function _handleFail(msg, err) {
95
+ if (err) {
96
+ throw err;
97
+ }
98
+ console.error(msg);
99
+ process.exit(1);
100
+ }
101
+
102
+ process.stdout.on('error', err => err.code == 'EPIPE' && process.exit(0));
package/cli/upload.js ADDED
@@ -0,0 +1,114 @@
1
+ import split2 from 'split2';
2
+ import { log, stream } from '@miso.ai/server-commons';
3
+ import { MisoClient, logger } from '../src/index.js';
4
+
5
+ function build(yargs) {
6
+ return yargs
7
+ .option('async', {
8
+ alias: ['a'],
9
+ describe: 'Asynchrnous mode',
10
+ })
11
+ .option('dry-run', {
12
+ alias: ['dry'],
13
+ describe: 'Dry run mode',
14
+ })
15
+ .option('records-per-request', {
16
+ alias: ['rpr'],
17
+ describe: 'How many records to send in a request',
18
+ })
19
+ .option('bytes-per-request', {
20
+ alias: ['bpr'],
21
+ describe: 'How many bytes to send in a request',
22
+ })
23
+ .option('bytes-per-second', {
24
+ alias: ['bps'],
25
+ describe: 'How many bytes to send per second',
26
+ })
27
+ .option('debug', {
28
+ describe: 'Set log level to debug',
29
+ type: 'boolean',
30
+ })
31
+ .option('progress', {
32
+ alias: ['p'],
33
+ describe: 'Set log format progress',
34
+ type: 'boolean',
35
+ })
36
+ .option('stream-name', {
37
+ alias: ['name'],
38
+ describe: 'Stream name that shows up in log messages',
39
+ })
40
+ .option('legacy', {
41
+ type: 'boolean',
42
+ default: false,
43
+ })
44
+ .hide('legacy')
45
+ .option('log-level', {
46
+ describe: 'Log level',
47
+ })
48
+ .option('log-format', {
49
+ describe: 'Log format',
50
+ });
51
+ }
52
+
53
+ const run = type => async ({
54
+ key,
55
+ server,
56
+ param: params,
57
+ async,
58
+ ['dry-run']: dryRun,
59
+ ['records-per-request']: recordsPerRequest,
60
+ ['bytes-per-request']: bytesPerRequest,
61
+ ['bytes-per-second']: bytesPerSecond,
62
+ ['experiment-id']: experimentId,
63
+ debug,
64
+ progress,
65
+ ['stream-name']: name,
66
+ legacy,
67
+ ['log-level']: loglevel,
68
+ ['log-format']: logFormat,
69
+ }) => {
70
+
71
+ loglevel = (debug || progress) ? log.DEBUG : loglevel;
72
+ logFormat = progress ? logger.FORMAT.PROGRESS : logFormat;
73
+
74
+ const client = new MisoClient({ key, server });
75
+
76
+ const uploadStream = client.createUploadStream(type, {
77
+ legacy,
78
+ name,
79
+ async,
80
+ dryRun,
81
+ params,
82
+ heartbeatInterval: logFormat === logger.FORMAT.PROGRESS ? 250 : false,
83
+ //heartbeat: logFormat === logger.FORMAT.PROGRESS ? 250 : undefined,
84
+ recordsPerRequest,
85
+ bytesPerRequest,
86
+ bytesPerSecond,
87
+ experimentId,
88
+ });
89
+
90
+ const logStream = logger.createLogStream({
91
+ api: 'upload',
92
+ type,
93
+ legacy,
94
+ level: loglevel,
95
+ format: logFormat,
96
+ });
97
+
98
+ await stream.pipeline(
99
+ process.stdin,
100
+ split2(),
101
+ uploadStream,
102
+ logStream,
103
+ );
104
+ };
105
+
106
+ export default function(type) {
107
+ return {
108
+ command: 'upload',
109
+ aliases: ['u'],
110
+ description: `Upload ${type}`,
111
+ builder: build,
112
+ handler: run(type),
113
+ };
114
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@miso.ai/server-sdk",
3
+ "description": "Miso SDK for Node.js",
4
+ "type": "module",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "miso": "cli/index.js"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "scripts": {},
13
+ "repository": "MisoAI/miso-server-js-sdk",
14
+ "license": "MIT",
15
+ "contributors": [
16
+ "simonpai <simon.pai@askmiso.com>"
17
+ ],
18
+ "dependencies": {
19
+ "@miso.ai/server-commons": "0.6.0-beta.0",
20
+ "axios": "^0.27.2",
21
+ "dotenv": "^16.0.1",
22
+ "split2": "^4.1.0",
23
+ "yargs": "^17.5.1"
24
+ },
25
+ "version": "0.6.0-beta.0"
26
+ }
package/src/client.js ADDED
@@ -0,0 +1,124 @@
1
+ import { asArray } from '@miso.ai/server-commons';
2
+ //import { createHash } from 'crypto';
3
+ import { Buffer } from 'buffer';
4
+ import axios from 'axios';
5
+ import version from './version.js';
6
+ import LegacyUploadStream from './stream/upload.legacy.js';
7
+ import UploadStream from './stream/upload.js';
8
+ import DeleteStream from './stream/delete.js';
9
+
10
+ export default class MisoClient {
11
+
12
+ static version = version;
13
+
14
+ constructor(options) {
15
+ this._options = normalizeOptions(options);
16
+ this.version = version;
17
+ }
18
+
19
+ async upload(type, records, options) {
20
+ const url = buildUrl(this, type, options);
21
+ const payload = buildPayload(records);
22
+ return await axios.post(url, payload);
23
+ }
24
+
25
+ // TODO: extract to .experiment() later
26
+ async uploadExperimentEvent(experimentId, record) {
27
+ // TODO: support non-string record
28
+ const url = buildUrl(this, `experiments/${experimentId}/events`);
29
+ // TODO: make content type header global
30
+ const headers = { 'Content-Type': 'application/json' };
31
+ const response = await axios.post(url, record, { headers });
32
+ // 200 response body does not have .data layer
33
+ return response.data ? response : { data: response };
34
+ }
35
+
36
+ async ids(type) {
37
+ const url = buildUrl(this, `${type}/_ids`);
38
+ return (await axios.get(url)).data.data.ids;
39
+ }
40
+
41
+ async delete(type, ids) {
42
+ if (type !== 'products' && type !== 'users') {
43
+ throw new Error(`Only products and users are supported: ${type}`);
44
+ }
45
+ ids = asArray(ids);
46
+ if (ids.length === 0) {
47
+ return { data: {} };
48
+ }
49
+ const payload = {
50
+ data: {
51
+ [type === 'products' ? 'product_ids' : 'user_ids']: ids,
52
+ },
53
+ };
54
+ return this._delete(type, payload);
55
+ }
56
+
57
+ async _delete(type, payload, options) {
58
+ const url = buildUrl(this, `${type}/_delete`, options);
59
+ const { data } = await axios.post(url, payload, {
60
+ headers: {
61
+ 'Content-Type': 'application/json',
62
+ },
63
+ });
64
+ return { data };
65
+ }
66
+
67
+ createUploadStream(type, options) {
68
+ if (options.legacy) {
69
+ options.heartbeat = options.heartbeatInvertal;
70
+ delete options.heartbeatInvertal;
71
+ return new LegacyUploadStream(this, type, options);
72
+ }
73
+ return new UploadStream(this, type, options);
74
+ }
75
+
76
+ createDeleteStream(type, options) {
77
+ return new DeleteStream(this, type, options);
78
+ }
79
+
80
+ get options() {
81
+ const { server, key } = this._options;
82
+ return Object.freeze({
83
+ server,
84
+ keyMasked: key.substring(0, 4) + '*'.repeat(Math.max(0, key.length - 4)),
85
+ //keyMd5: createHash('md5').update(key).digest('hex'),
86
+ });
87
+ }
88
+
89
+ }
90
+
91
+ function normalizeOptions(options) {
92
+ if (typeof options === 'string') {
93
+ options = { key: options };
94
+ }
95
+ if (!options.key || typeof options.key !== 'string') {
96
+ throw new Error(`API key is required.`);
97
+ }
98
+ options.server = options.server || 'https://api.askmiso.com'
99
+
100
+ return options;
101
+ }
102
+
103
+ function buildUrl(client, path, { async, dryRun, params: extraParams } = {}) {
104
+ let { server, key } = client._options;
105
+ let params = `?api_key=${key}`;
106
+ if (dryRun) {
107
+ params += '&dry_run=1';
108
+ } else if (async) {
109
+ params += '&async=1';
110
+ }
111
+ if (extraParams) {
112
+ for (const key in extraParams) {
113
+ // TODO: deal with encodeURIComponent
114
+ params += `&${key}=${extraParams[key]}`;
115
+ }
116
+ }
117
+ return `${server}/v1/${path}${params}`;
118
+ }
119
+
120
+ function buildPayload(records) {
121
+ return typeof records === 'string' ? records :
122
+ Buffer.isBuffer(records) ? records.toString() :
123
+ { data: Array.isArray(records)? records : [records] };
124
+ }
package/src/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { default as MisoClient } from './client.js';
2
+ export * as logger from './logger/index.js';
@@ -0,0 +1,106 @@
1
+ import { log, stream } from '@miso.ai/server-commons';
2
+
3
+ const { formatDuration, formatBytes, formatTable } = log;
4
+
5
+ export default class ApiProgressLogStream extends stream.LogUpdateStream {
6
+
7
+ _renderError({ state: _, ...record }) {
8
+ return super._renderError(record);
9
+ }
10
+
11
+ _render(args) {
12
+ args = this._normalizeInput(args);
13
+ return this._sections(args)
14
+ .map(s => `\n${s}\n`)
15
+ .join('');
16
+ }
17
+
18
+ _normalizeInput({ config, ...args } = {}) {
19
+ return {
20
+ config: (this._config = (config || this._config)),
21
+ ...args,
22
+ };
23
+ }
24
+
25
+ _sections({ config, state }) {
26
+ return [
27
+ this._configTable(config),
28
+ this._statusTable(state),
29
+ this._timeStatsTable(state),
30
+ this._dataStatsTable(state),
31
+ ];
32
+ }
33
+
34
+ _configTable(config) {
35
+ const { name = '(anonymous)', client = {} } = config || {};
36
+ return formatTable([
37
+ ['Job:', `${name}`],
38
+ ['Server:', `${client.server || '(default)'}`],
39
+ ['API Key:', `${client.keyMasked}`],
40
+ ]);
41
+ }
42
+
43
+ _statusTable(state) {
44
+ const { bps, stats } = state;
45
+ const serviceBps = stats && stats.service && stats.service.bps;
46
+ return formatTable([
47
+ ['Status:', `${this._statusLine(state)}`],
48
+ ['Service Speed:', this._formatBps(serviceBps)],
49
+ ['Client Speed:', this._formatBps(bps)],
50
+ ]);
51
+ }
52
+
53
+ _statusLine({ status, time }) {
54
+ switch (status) {
55
+ case 'paused':
56
+ const { currentTime, willResumeAt } = time;
57
+ const willResumeIn = Math.max(0, willResumeAt - currentTime);
58
+ return `${status.toUpperCase()} (resume in ${formatDuration(willResumeIn)})`;
59
+ default:
60
+ return status.toUpperCase();
61
+ }
62
+ }
63
+
64
+ _timeStatsTable({ time }) {
65
+ const { total, waiting, running, paused } = time;
66
+ return formatTable([
67
+ ['Total Time', 'Preparing', 'Running', 'Paused'],
68
+ [formatDuration(total), formatDuration(waiting), formatDuration(running), formatDuration(paused)],
69
+ ]);
70
+ }
71
+
72
+ _dataStatsTable({ pending, successful, failed }) {
73
+ const pendingSums = this._sumMetrics(pending);
74
+
75
+ return formatTable([
76
+ ['', 'Requests', 'Records', 'Bytes'],
77
+ ['Pending', pendingSums.requests, pendingSums.records, formatBytes(pendingSums.bytes)],
78
+ ['Successful', successful.requests, successful.records, formatBytes(successful.bytes)],
79
+ ['Failed', failed.requests, failed.records, formatBytes(failed.bytes)],
80
+ ]);
81
+ }
82
+
83
+ // helper //
84
+ _sumMetrics(requests) {
85
+ return requests.reduce((acc, req) => {
86
+ acc.requests ++;
87
+ acc.records += req.records;
88
+ acc.bytes += req.bytes;
89
+ return acc;
90
+ }, {
91
+ requests: 0,
92
+ records: 0,
93
+ bytes: 0,
94
+ });
95
+ }
96
+
97
+ // TODO: move to log utils
98
+ _formatBps(value) {
99
+ return isNaN(value) ? '--' : `${formatBytes(value)}/s`;
100
+ }
101
+
102
+ _formatRps(value) {
103
+ return isNaN(value) ? '--' : `${value} records/s`;
104
+ }
105
+
106
+ }
@@ -0,0 +1,5 @@
1
+ export const FORMAT = {
2
+ TEXT: 'text',
3
+ JSON: 'json',
4
+ PROGRESS: 'progress',
5
+ };
@@ -0,0 +1,22 @@
1
+ import { log } from '@miso.ai/server-commons';
2
+ import ApiProgressLogStream from './api-progress.js';
3
+
4
+ const { formatTable } = log;
5
+
6
+ export default class DeleteProgressLogStream extends ApiProgressLogStream {
7
+
8
+ _dataStatsTable({ pending, successful, failed, stats }) {
9
+ const { deletion } = stats;
10
+ const pendingSums = this._sumMetrics(pending);
11
+
12
+ return formatTable([
13
+ ['', 'Requests', 'Records'],
14
+ ['Pending', pendingSums.requests, pendingSums.records],
15
+ ['Successful', successful.requests, successful.records],
16
+ ['- Deleted', '', deletion.deleted],
17
+ ['- Not Found', '', deletion.notFound],
18
+ ['Failed', failed.requests, failed.records],
19
+ ]);
20
+ }
21
+
22
+ }
@@ -0,0 +1,41 @@
1
+ import { FORMAT } from './constants.js';
2
+ import StandardLogStream from './standard.js';
3
+ import LegacyProgressLogStream from './progress.legacy.js';
4
+ import ApiProgressLogStream from './api-progress.js';
5
+ import DeleteProgressLogStream from './delete-progress.js';
6
+
7
+ export * from './constants.js';
8
+
9
+ export function createLogStream({
10
+ api,
11
+ legacy,
12
+ level,
13
+ format,
14
+ out,
15
+ err,
16
+ }) {
17
+ switch (format || FORMAT.JSON) {
18
+ case FORMAT.PROGRESS:
19
+ switch (api) {
20
+ case 'upload':
21
+ if (legacy) {
22
+ return new LegacyProgressLogStream({ out, err });
23
+ }
24
+ return new ApiProgressLogStream({ out, err });
25
+ case 'delete':
26
+ return new DeleteProgressLogStream({ out, err });
27
+ default:
28
+ throw new Error(`Unsupported API: ${api}`);
29
+ }
30
+ case FORMAT.TEXT:
31
+ case FORMAT.JSON:
32
+ return new StandardLogStream({
33
+ level,
34
+ format,
35
+ out,
36
+ err,
37
+ });
38
+ default:
39
+ throw new Error(`Unrecognized log format: ${format}`);
40
+ }
41
+ }