@@ -0,0 +1 @@
|
||||
declare module "emoji-name-map";
|
||||
@@ -0,0 +1,21 @@
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import SocketIO from "socket.io";
|
||||
import { User } from "../utils/types";
|
||||
|
||||
export interface authenticateDecorator {
|
||||
(request: FastifyRequest): FastifyRequest;
|
||||
(reply: FastifyReply): FastifyReply;
|
||||
}
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
phpnodeAuthenticate(): void;
|
||||
authenticate(): void;
|
||||
authenticateOptional(): void;
|
||||
io: SocketIO.Server;
|
||||
}
|
||||
|
||||
interface FastifyRequest {
|
||||
currentUser: User;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
declare module "free-email-domains";
|
||||
@@ -0,0 +1 @@
|
||||
declare module "get-website-favicon";
|
||||
@@ -0,0 +1 @@
|
||||
declare module "multistream";
|
||||
@@ -0,0 +1 @@
|
||||
declare module "unoconv-promise";
|
||||
@@ -0,0 +1 @@
|
||||
declare module "uuid-time";
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CommandModule } from "yargs";
|
||||
|
||||
const command: CommandModule = {
|
||||
describe: "Manage Tdrive Applications",
|
||||
command: "applications <command>",
|
||||
builder: yargs =>
|
||||
yargs.commandDir("applications_cmds", {
|
||||
visit: commandModule => commandModule.default,
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
handler: () => {},
|
||||
};
|
||||
|
||||
export default command;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CommandModule } from "yargs";
|
||||
|
||||
const command: CommandModule = {
|
||||
describe: "Export Tdrive data",
|
||||
command: "export <command>",
|
||||
builder: yargs =>
|
||||
yargs.commandDir("export_cmds", {
|
||||
visit: commandModule => commandModule.default,
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
handler: () => {},
|
||||
};
|
||||
|
||||
export default command;
|
||||
@@ -0,0 +1,197 @@
|
||||
import yargs from "yargs";
|
||||
import tdrive from "../../../tdrive";
|
||||
import { mkdirSync, writeFileSync } from "fs";
|
||||
import { Pagination } from "../../../core/platform/framework/api/crud-service";
|
||||
import WorkspaceUser from "../../../services/workspaces/entities/workspace_user";
|
||||
import { ChannelVisibility } from "../../../services/channels/types";
|
||||
import { Channel, ChannelMember } from "../../../services/channels/entities";
|
||||
import { formatCompany } from "../../../services/user/utils";
|
||||
import gr from "../../../services/global-resolver";
|
||||
import { formatUser } from "../../../utils/users";
|
||||
|
||||
/**
|
||||
* Merge command parameters. Check the builder definition below for more details.
|
||||
*/
|
||||
type CLIArgs = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const services = [
|
||||
"auth",
|
||||
"storage",
|
||||
"counter",
|
||||
"message-queue",
|
||||
"user",
|
||||
"files",
|
||||
"messages",
|
||||
"workspaces",
|
||||
"platform-services",
|
||||
"console",
|
||||
"applications",
|
||||
"search",
|
||||
"database",
|
||||
"webserver",
|
||||
"channels",
|
||||
"statistics",
|
||||
];
|
||||
|
||||
const command: yargs.CommandModule<unknown, CLIArgs> = {
|
||||
command: "company",
|
||||
describe:
|
||||
"command to export everything inside a company (publicly data only available to a new member)",
|
||||
builder: {
|
||||
id: {
|
||||
default: "",
|
||||
type: "string",
|
||||
description: "Company ID",
|
||||
},
|
||||
output: {
|
||||
default: "",
|
||||
type: "string",
|
||||
description: "Folder containing the exported data",
|
||||
},
|
||||
},
|
||||
handler: async argv => {
|
||||
const platform = await tdrive.run(services);
|
||||
await gr.doInit(platform);
|
||||
|
||||
const company = await gr.services.companies.getCompany({ id: argv.id });
|
||||
|
||||
if (!company) {
|
||||
return "No such company";
|
||||
}
|
||||
|
||||
console.log(`Start export for company ${company.id}`);
|
||||
|
||||
const output = (argv.output as string) || `export-${company.id}`;
|
||||
mkdirSync(output, { recursive: true });
|
||||
|
||||
//Company
|
||||
console.log("- Create company json file");
|
||||
writeFileSync(`${output}/company.json`, JSON.stringify(formatCompany(company)));
|
||||
|
||||
//Workspaces
|
||||
console.log("- Create workspaces json file");
|
||||
const workspaces = await gr.services.workspaces.getAllForCompany(company.id);
|
||||
writeFileSync(`${output}/workspaces.json`, JSON.stringify(workspaces));
|
||||
|
||||
//Users
|
||||
console.log("- Create users json file");
|
||||
const users = [];
|
||||
for (const workspace of workspaces) {
|
||||
const workspace_users = [];
|
||||
let workspaceUsers: WorkspaceUser[] = [];
|
||||
let pagination = new Pagination();
|
||||
do {
|
||||
const res = await gr.services.workspaces.getUsers(
|
||||
{ workspaceId: workspace.id },
|
||||
pagination,
|
||||
);
|
||||
workspaceUsers = [...workspaceUsers, ...res.getEntities()];
|
||||
pagination = res.nextPage as Pagination;
|
||||
} while (pagination.page_token);
|
||||
for (const workspaceUser of workspaceUsers) {
|
||||
const user = await gr.services.users.get({ id: workspaceUser.userId });
|
||||
if (user) {
|
||||
users.push(await formatUser(user));
|
||||
workspace_users.push({ ...workspaceUser, user });
|
||||
}
|
||||
}
|
||||
mkdirSync(`${output}/workspaces/${workspace.id}`, { recursive: true });
|
||||
writeFileSync(
|
||||
`${output}/workspaces/${workspace.id}/users.json`,
|
||||
JSON.stringify(workspace_users),
|
||||
);
|
||||
}
|
||||
writeFileSync(`${output}/users.json`, JSON.stringify(users));
|
||||
|
||||
//Channels
|
||||
console.log("- Create channels json file");
|
||||
const directChannels: Channel[] = [];
|
||||
let allPublicChannels: Channel[] = [];
|
||||
|
||||
let pagination = new Pagination();
|
||||
do {
|
||||
const page = await gr.services.channels.channels.getDirectChannelsInCompany(
|
||||
pagination,
|
||||
company.id,
|
||||
undefined,
|
||||
);
|
||||
for (const channel of page.getEntities()) {
|
||||
const channelDetail = await gr.services.channels.channels.get(
|
||||
{
|
||||
company_id: channel.company_id,
|
||||
workspace_id: "direct",
|
||||
id: channel.id,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
directChannels.push(channelDetail);
|
||||
}
|
||||
pagination = page.nextPage as Pagination;
|
||||
} while (pagination.page_token);
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
let pagination = new Pagination();
|
||||
|
||||
let publicChannels: Channel[] = [];
|
||||
pagination = new Pagination();
|
||||
do {
|
||||
const page = await gr.services.channels.channels.list(
|
||||
pagination,
|
||||
{},
|
||||
{
|
||||
user: { id: "", server_request: true },
|
||||
workspace: { workspace_id: workspace.id, company_id: company.id },
|
||||
},
|
||||
);
|
||||
const chans = page.getEntities().filter(c => c.visibility == ChannelVisibility.PUBLIC);
|
||||
allPublicChannels = [...allPublicChannels, ...chans];
|
||||
publicChannels = [...publicChannels, ...chans];
|
||||
pagination = page.nextPage as Pagination;
|
||||
} while (pagination.page_token);
|
||||
|
||||
mkdirSync(`${output}/workspaces/${workspace.id}`, { recursive: true });
|
||||
writeFileSync(
|
||||
`${output}/workspaces/${workspace.id}/channels.json`,
|
||||
JSON.stringify(publicChannels),
|
||||
);
|
||||
}
|
||||
writeFileSync(`${output}/direct_channels.json`, JSON.stringify(directChannels));
|
||||
|
||||
//Channels users
|
||||
console.log("- Create channels users json file");
|
||||
for (const channel of [...allPublicChannels /*, ...directChannels*/]) {
|
||||
let members: ChannelMember[] = [];
|
||||
let pagination = new Pagination();
|
||||
do {
|
||||
const page = await gr.services.channels.members.list(
|
||||
pagination,
|
||||
{},
|
||||
{
|
||||
user: { id: "", server_request: true },
|
||||
channel: {
|
||||
company_id: channel.company_id,
|
||||
workspace_id: channel.workspace_id,
|
||||
id: channel.id,
|
||||
},
|
||||
},
|
||||
);
|
||||
members = [...members, ...page.getEntities()] as ChannelMember[];
|
||||
pagination = page.nextPage as Pagination;
|
||||
} while (pagination.page_token);
|
||||
|
||||
mkdirSync(`${output}/workspaces/${channel.workspace_id}/channels/${channel.id}`, {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
`${output}/workspaces/${channel.workspace_id}/channels/${channel.id}/members.json`,
|
||||
JSON.stringify(members),
|
||||
);
|
||||
}
|
||||
|
||||
await platform.stop();
|
||||
},
|
||||
};
|
||||
|
||||
export default command;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CommandModule } from "yargs";
|
||||
|
||||
const command: CommandModule = {
|
||||
describe: "Generate Tdrive data",
|
||||
command: "generate <command>",
|
||||
builder: yargs =>
|
||||
yargs.commandDir("generate_cmds", {
|
||||
visit: commandModule => commandModule.default,
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
handler: () => {},
|
||||
};
|
||||
|
||||
export default command;
|
||||
@@ -0,0 +1,147 @@
|
||||
import passwordGenerator from "generate-password";
|
||||
import { from, pipe } from "rxjs";
|
||||
import { bufferCount, first, map, mergeMap, switchMap, tap } from "rxjs/operators";
|
||||
import { v1 as uuid } from "uuid";
|
||||
import yargs from "yargs";
|
||||
|
||||
import Company, {
|
||||
getInstance as getCompanyInstance,
|
||||
} from "../../../services/user/entities/company";
|
||||
import CompanyUser from "../../../services/user/entities/company_user";
|
||||
import tdrive from "../../../tdrive";
|
||||
import User, { getInstance as getUserInstance } from "../../../services/user/entities/user";
|
||||
import gr from "../../../services/global-resolver";
|
||||
|
||||
type CLIArgs = {
|
||||
company: number;
|
||||
user: number;
|
||||
concurrent: number;
|
||||
};
|
||||
|
||||
const services = [
|
||||
"storage",
|
||||
"counter",
|
||||
"applications",
|
||||
"statistics",
|
||||
"auth",
|
||||
"realtime",
|
||||
"push",
|
||||
"platform-services",
|
||||
"user",
|
||||
"search",
|
||||
"channels",
|
||||
"notifications",
|
||||
"database",
|
||||
"webserver",
|
||||
"message-queue",
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
const command: yargs.CommandModule<{}, CLIArgs> = {
|
||||
command: "users",
|
||||
describe: "Generate users and companies",
|
||||
builder: {
|
||||
company: {
|
||||
alias: "c",
|
||||
default: 3,
|
||||
type: "number",
|
||||
description: "Number of companies to generate",
|
||||
},
|
||||
user: {
|
||||
alias: "u",
|
||||
default: 5,
|
||||
type: "number",
|
||||
description: "Number of users to generate in the company",
|
||||
},
|
||||
concurrent: {
|
||||
default: 1,
|
||||
type: "number",
|
||||
description: "Number of concurrent creation tasks",
|
||||
},
|
||||
},
|
||||
handler: async argv => {
|
||||
const concurrentTasks = argv.concurrent;
|
||||
const nbUsersPerCompany = argv.user;
|
||||
const nbCompanies = argv.company;
|
||||
const platform = await tdrive.run(services);
|
||||
await gr.doInit(platform);
|
||||
const companies = getCompanies(nbCompanies);
|
||||
const createUser = async (userInCompany: {
|
||||
user: User;
|
||||
company: Company;
|
||||
}): Promise<CompanyUser> => {
|
||||
console.log("Creating user", userInCompany);
|
||||
const created = await gr.services.users.create(getUserInstance(userInCompany.user));
|
||||
|
||||
return (await gr.services.companies.setUserRole(userInCompany.company.id, created.entity.id))
|
||||
?.entity;
|
||||
};
|
||||
const createCompany = (company: Company) => {
|
||||
console.log("Creating company", company);
|
||||
return gr.services.companies.createCompany(company);
|
||||
};
|
||||
|
||||
const obsv$ = from(companies).pipe(
|
||||
// Create companies sequentially
|
||||
mergeMap(company => createCompany(company), concurrentTasks),
|
||||
// until we create enough companies
|
||||
bufferCount(companies.length),
|
||||
tap(companies => console.log("Created companies", companies.length)),
|
||||
// for each created company
|
||||
switchMap(companies => from(companies)),
|
||||
// generate a set of user for each company
|
||||
map(company => getUsersForCompany(company, nbUsersPerCompany)),
|
||||
// Create users sequentially
|
||||
pipe(
|
||||
switchMap(userCompanies => from(userCompanies)),
|
||||
mergeMap(userCompany => createUser(userCompany), concurrentTasks),
|
||||
// until we reach the number of users in the company
|
||||
bufferCount(nbUsersPerCompany),
|
||||
),
|
||||
// until we reach the number of companies
|
||||
bufferCount(companies.length),
|
||||
first(),
|
||||
);
|
||||
|
||||
return obsv$
|
||||
.toPromise()
|
||||
.then(() => platform.stop())
|
||||
.finally(() => console.log("✅ Company users are now created"));
|
||||
},
|
||||
};
|
||||
|
||||
const getCompanies = (size: number = 10): Company[] => {
|
||||
return [...Array(size).keys()].map(i =>
|
||||
getCompanyInstance({
|
||||
id: uuid(),
|
||||
name: `tdrive${i}.app`,
|
||||
displayName: `My Tdrive Company #${i}`,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const getUsersForCompany = (company: Company, size: number = 10) => {
|
||||
return getUsers(company, size).map(user => ({
|
||||
user,
|
||||
company,
|
||||
}));
|
||||
};
|
||||
|
||||
const getUsers = (company: Company, size: number = 100): User[] => {
|
||||
return [...Array(size).keys()].map(i => getUser(company, i));
|
||||
};
|
||||
|
||||
const getUser = (company: Company, id: number): User => {
|
||||
return getUserInstance({
|
||||
id: uuid(),
|
||||
first_name: "John",
|
||||
last_name: `Doe${id}`,
|
||||
email_canonical: `user${id}@${company.name}`,
|
||||
password: passwordGenerator.generate({
|
||||
length: 10,
|
||||
numbers: true,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default command;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CommandModule } from "yargs";
|
||||
|
||||
const command: CommandModule = {
|
||||
describe: "Migrate your php message to node",
|
||||
command: "migration <command>",
|
||||
builder: yargs =>
|
||||
yargs.commandDir("migration_cmds", {
|
||||
visit: commandModule => commandModule.default,
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
handler: () => {},
|
||||
};
|
||||
|
||||
export default command;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CommandModule } from "yargs";
|
||||
|
||||
const command: CommandModule = {
|
||||
describe: "Work with search middleware",
|
||||
command: "search <command>",
|
||||
builder: yargs =>
|
||||
yargs.commandDir("search_cmds", {
|
||||
visit: commandModule => commandModule.default,
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
handler: () => {},
|
||||
};
|
||||
|
||||
export default command;
|
||||
@@ -0,0 +1,143 @@
|
||||
import yargs from "yargs";
|
||||
import tdrive from "../../../tdrive";
|
||||
import ora from "ora";
|
||||
import { TdrivePlatform } from "../../../core/platform/platform";
|
||||
import { DatabaseServiceAPI } from "../../../core/platform/services/database/api";
|
||||
import { Pagination } from "../../../core/platform/framework/api/crud-service";
|
||||
|
||||
import User, { TYPE as UserTYPE } from "../../../services/user/entities/user";
|
||||
import { Channel } from "../../../services/channels/entities";
|
||||
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
|
||||
import { SearchServiceAPI } from "../../../core/platform/services/search/api";
|
||||
import CompanyUser, { TYPE as CompanyUserTYPE } from "../../../services/user/entities/company_user";
|
||||
import gr from "../../../services/global-resolver";
|
||||
|
||||
type Options = {
|
||||
repository?: string;
|
||||
repairEntities?: boolean;
|
||||
};
|
||||
|
||||
class SearchIndexAll {
|
||||
database: DatabaseServiceAPI;
|
||||
search: SearchServiceAPI;
|
||||
|
||||
constructor(readonly platform: TdrivePlatform) {
|
||||
this.database = this.platform.getProvider<DatabaseServiceAPI>("database");
|
||||
this.search = this.platform.getProvider<SearchServiceAPI>("search");
|
||||
}
|
||||
|
||||
public async run(options: Options = {}): Promise<void> {
|
||||
const repositories: Map<string, Repository<any>> = new Map();
|
||||
repositories.set("users", await this.database.getRepository(UserTYPE, User));
|
||||
repositories.set("channels", await this.database.getRepository("channels", Channel));
|
||||
|
||||
const repository = repositories.get(options.repository);
|
||||
if (!repository) {
|
||||
throw (
|
||||
"No such repository ready for indexation, available are: " +
|
||||
Array.from(repositories.keys()).join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
// Complete user with companies in cache
|
||||
if (options.repository === "users" && options.repairEntities) {
|
||||
console.log("Complete user with companies in cache");
|
||||
const companiesUsersRepository = await this.database.getRepository(
|
||||
CompanyUserTYPE,
|
||||
CompanyUser,
|
||||
);
|
||||
const userRepository = await this.database.getRepository(UserTYPE, User);
|
||||
let page: Pagination = { limitStr: "100" };
|
||||
// For each rows
|
||||
do {
|
||||
const list = await userRepository.find({}, { pagination: page }, undefined);
|
||||
|
||||
for (const user of list.getEntities()) {
|
||||
const companies = await companiesUsersRepository.find(
|
||||
{ user_id: user.id },
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
|
||||
user.cache ||= { companies: [] };
|
||||
user.cache.companies = companies.getEntities().map(company => company.group_id);
|
||||
await repositories.get("users").save(user, undefined);
|
||||
}
|
||||
|
||||
page = list.nextPage as Pagination;
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
} while (page.page_token);
|
||||
}
|
||||
|
||||
console.log("Start indexing...");
|
||||
let count = 0;
|
||||
// Get all items
|
||||
let page: Pagination = { limitStr: "100" };
|
||||
do {
|
||||
console.log("Indexed " + count + " items...");
|
||||
const list = await repository.find({}, { pagination: page }, undefined);
|
||||
page = list.nextPage as Pagination;
|
||||
await this.search.upsert(list.getEntities());
|
||||
count += list.getEntities().length;
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
} while (page.page_token);
|
||||
|
||||
console.log("Emptying flush (10s)...");
|
||||
await new Promise(r => setTimeout(r, 10000));
|
||||
|
||||
console.log("Done!");
|
||||
}
|
||||
}
|
||||
|
||||
const services = [
|
||||
"search",
|
||||
"database",
|
||||
"webserver",
|
||||
"auth",
|
||||
"counter",
|
||||
"cron",
|
||||
"message-queue",
|
||||
"push",
|
||||
"realtime",
|
||||
"storage",
|
||||
"tracker",
|
||||
"websocket",
|
||||
];
|
||||
|
||||
const command: yargs.CommandModule<unknown, unknown> = {
|
||||
command: "index",
|
||||
describe: "command to reindex search middleware from db entities",
|
||||
builder: {
|
||||
repository: {
|
||||
default: "",
|
||||
type: "string",
|
||||
description: "Choose a repository to reindex",
|
||||
},
|
||||
repairEntities: {
|
||||
default: false,
|
||||
type: "boolean",
|
||||
description: "Choose to repair entities too when possible",
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
handler: async argv => {
|
||||
const spinner = ora({ text: "Reindex repository - " }).start();
|
||||
const platform = await tdrive.run(services);
|
||||
await gr.doInit(platform);
|
||||
const migrator = new SearchIndexAll(platform);
|
||||
|
||||
const repository = (argv.repository || "") as string;
|
||||
|
||||
if (!repository) {
|
||||
throw "No repository was set.";
|
||||
}
|
||||
|
||||
await migrator.run({
|
||||
repository,
|
||||
});
|
||||
|
||||
return spinner.stop();
|
||||
},
|
||||
};
|
||||
|
||||
export default command;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CommandModule } from "yargs";
|
||||
|
||||
const command: CommandModule = {
|
||||
describe: "Manage Tdrive Users",
|
||||
command: "users <command>",
|
||||
builder: yargs =>
|
||||
yargs.commandDir("users_cmds", {
|
||||
visit: commandModule => commandModule.default,
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
handler: () => {},
|
||||
};
|
||||
|
||||
export default command;
|
||||
@@ -0,0 +1,85 @@
|
||||
import yargs from "yargs";
|
||||
import ora from "ora";
|
||||
import tdrive from "../../../tdrive";
|
||||
import Table from "cli-table";
|
||||
import { exit } from "process";
|
||||
import gr from "../../../services/global-resolver";
|
||||
|
||||
/**
|
||||
* Merge command parameters. Check the builder definition below for more details.
|
||||
*/
|
||||
type CLIArgs = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const services = [
|
||||
"storage",
|
||||
"counter",
|
||||
"message-queue",
|
||||
"platform-services",
|
||||
"user",
|
||||
"search",
|
||||
"database",
|
||||
"webserver",
|
||||
"statistics",
|
||||
"applications",
|
||||
"auth",
|
||||
"realtime",
|
||||
"websocket",
|
||||
];
|
||||
|
||||
const command: yargs.CommandModule<unknown, CLIArgs> = {
|
||||
command: "remove",
|
||||
describe: "command that allow you to remove one user",
|
||||
builder: {
|
||||
id: {
|
||||
default: "",
|
||||
type: "string",
|
||||
description: "User ID",
|
||||
},
|
||||
},
|
||||
handler: async argv => {
|
||||
const tableBefore = new Table({
|
||||
head: ["User ID", "Username", "Deleted"],
|
||||
colWidths: [40, 40, 10],
|
||||
});
|
||||
const tableAfter = new Table({
|
||||
head: ["User ID", "Username", "Deleted"],
|
||||
colWidths: [40, 40, 10],
|
||||
});
|
||||
const spinner = ora({ text: "Retrieving user" }).start();
|
||||
|
||||
const platform = await tdrive.run(services);
|
||||
await gr.doInit(platform);
|
||||
|
||||
const user = await gr.services.users.get({ id: argv.id });
|
||||
|
||||
if (!user) {
|
||||
console.error("Error: You need to provide User ID");
|
||||
return spinner.stop();
|
||||
}
|
||||
|
||||
if (user) {
|
||||
// Table before
|
||||
tableBefore.push([user.id, user.username_canonical, user.deleted]);
|
||||
|
||||
await gr.services.users.anonymizeAndDelete(
|
||||
{ id: user.id },
|
||||
{
|
||||
user: { id: user.id, server_request: true },
|
||||
},
|
||||
);
|
||||
|
||||
const finalUser = await gr.services.users.get({ id: argv.id });
|
||||
|
||||
// Table after
|
||||
tableAfter.push([finalUser.id, finalUser.username_canonical, finalUser.deleted]);
|
||||
|
||||
spinner.stop();
|
||||
}
|
||||
|
||||
exit();
|
||||
},
|
||||
};
|
||||
|
||||
export default command;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CommandModule } from "yargs";
|
||||
|
||||
const command: CommandModule = {
|
||||
describe: "Manage Tdrive Workspaces",
|
||||
command: "workspace <command>",
|
||||
builder: yargs =>
|
||||
yargs.commandDir("workspace_cmds", {
|
||||
visit: commandModule => commandModule.default,
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
handler: () => {},
|
||||
};
|
||||
|
||||
export default command;
|
||||
@@ -0,0 +1,47 @@
|
||||
import yargs from "yargs";
|
||||
import ora from "ora";
|
||||
import Table from "cli-table";
|
||||
import tdrive from "../../../tdrive";
|
||||
import gr from "../../../services/global-resolver";
|
||||
/**
|
||||
* Merge command parameters. Check the builder definition below for more details.
|
||||
*/
|
||||
type ListParams = {
|
||||
size: string;
|
||||
};
|
||||
|
||||
const services = [
|
||||
"platform-services",
|
||||
"user",
|
||||
"search",
|
||||
"channels",
|
||||
"notifications",
|
||||
"database",
|
||||
"webserver",
|
||||
"message-queue",
|
||||
];
|
||||
|
||||
const command: yargs.CommandModule<ListParams, ListParams> = {
|
||||
command: "list",
|
||||
describe: "List Tdrive workspaces",
|
||||
builder: {
|
||||
size: {
|
||||
default: "50",
|
||||
type: "string",
|
||||
description: "Number of workspaces to fetch",
|
||||
},
|
||||
},
|
||||
handler: async argv => {
|
||||
const table = new Table({ head: ["ID", "Name"], colWidths: [40, 50] });
|
||||
const spinner = ora({ text: "List Tdrive workspaces" }).start();
|
||||
const platform = await tdrive.run(services);
|
||||
await gr.doInit(platform);
|
||||
const workspaces = await gr.services.workspaces.list({ limitStr: argv.size });
|
||||
|
||||
spinner.stop();
|
||||
workspaces.getEntities().forEach(ws => table.push([ws.id, ws.name]));
|
||||
console.log(table.toString());
|
||||
},
|
||||
};
|
||||
|
||||
export default command;
|
||||
@@ -0,0 +1,49 @@
|
||||
import yargs from "yargs";
|
||||
import ora from "ora";
|
||||
import Table from "cli-table";
|
||||
import tdrive from "../../../tdrive";
|
||||
import gr from "../../../services/global-resolver";
|
||||
/**
|
||||
* Merge command parameters. Check the builder definition below for more details.
|
||||
*/
|
||||
type ListParams = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const services = [
|
||||
"platform-services",
|
||||
"user",
|
||||
"search",
|
||||
"channels",
|
||||
"notifications",
|
||||
"database",
|
||||
"webserver",
|
||||
"message-queue",
|
||||
];
|
||||
|
||||
const command: yargs.CommandModule<ListParams, ListParams> = {
|
||||
command: "user",
|
||||
describe: "List workspace ers",
|
||||
builder: {
|
||||
id: {
|
||||
default: "f339d54a-e833-11ea-92c3-0242ac120004",
|
||||
type: "string",
|
||||
description: "Workspace ID",
|
||||
},
|
||||
},
|
||||
handler: async argv => {
|
||||
const table = new Table({ head: ["user ID", "Date Added"], colWidths: [40, 40] });
|
||||
const spinner = ora({ text: "Retrieving workspace users" }).start();
|
||||
const platform = await tdrive.run(services);
|
||||
await gr.doInit(platform);
|
||||
const users = await gr.services.workspaces.getUsers({ workspaceId: argv.id });
|
||||
|
||||
spinner.stop();
|
||||
users
|
||||
.getEntities()
|
||||
.forEach(u => table.push([u.id, new Date(u.dateAdded).toLocaleDateString()]));
|
||||
console.log(table.toString());
|
||||
},
|
||||
};
|
||||
|
||||
export default command;
|
||||
@@ -0,0 +1,28 @@
|
||||
import yargs from "yargs";
|
||||
import { logger } from "../core/platform/framework/logger";
|
||||
|
||||
process.env.NODE_ENV = "cli";
|
||||
|
||||
yargs
|
||||
.strict()
|
||||
.usage("Usage: $0 <command> [options]")
|
||||
.middleware([
|
||||
argv => {
|
||||
logger.level = argv.verbose ? "debug" : "fatal";
|
||||
},
|
||||
])
|
||||
.commandDir("cmds", {
|
||||
visit: commandModule => commandModule.default,
|
||||
})
|
||||
.option("verbose", {
|
||||
alias: "v",
|
||||
default: false,
|
||||
type: "boolean",
|
||||
description: "Run with verbose logging",
|
||||
})
|
||||
.demandCommand(1, "Please supply a valid command")
|
||||
.alias("help", "h")
|
||||
.help("help")
|
||||
.version()
|
||||
.epilogue("for more information, go to https://tdrive.app")
|
||||
.example("$0 <command> --help", "show help of the issue command").argv;
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
yarn.lock
|
||||
@@ -0,0 +1,4 @@
|
||||
Edit the file to your needs
|
||||
|
||||
yarn install
|
||||
node index.js
|
||||
@@ -0,0 +1,214 @@
|
||||
/* eslint-disable */
|
||||
var cassandra = require("cassandra-driver");
|
||||
|
||||
var fromAuthProvider = new cassandra.auth.PlainTextAuthProvider("", "");
|
||||
var fromContactPoints = [""];
|
||||
var fromKeyspace = "tdrive";
|
||||
|
||||
var toAuthProvider = new cassandra.auth.PlainTextAuthProvider("", "");
|
||||
var toContactPoints = [""];
|
||||
var toKeyspace = "tdrive";
|
||||
|
||||
var forceUpdateAll = false;
|
||||
var ignoreTables = ["notification"];
|
||||
var countersTables = ["statistics", "stats_counter", "channel_counters", "scheduled_queue_counter"];
|
||||
|
||||
var specialConversions = {
|
||||
"tdrive.group_user.date_added": value => {
|
||||
return new Date(value).getTime();
|
||||
},
|
||||
};
|
||||
|
||||
// -- start process
|
||||
|
||||
var fromClient = new cassandra.Client({
|
||||
localDataCenter: "datacenter1",
|
||||
contactPoints: fromContactPoints,
|
||||
authProvider: fromAuthProvider,
|
||||
keyspace: fromKeyspace,
|
||||
});
|
||||
|
||||
var toClient = new cassandra.Client({
|
||||
localDataCenter: "datacenter1",
|
||||
contactPoints: toContactPoints,
|
||||
authProvider: toAuthProvider,
|
||||
keyspace: toKeyspace,
|
||||
queryOptions: {
|
||||
consistency: cassandra.types.consistencies.quorum,
|
||||
},
|
||||
});
|
||||
|
||||
// -- Get all tables and copy schema
|
||||
|
||||
async function client(origin, query, parameters, options) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
origin.execute(query, [], {}, function (err, result) {
|
||||
if (err) {
|
||||
reject({ err, result });
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const result = await client(
|
||||
fromClient,
|
||||
"SELECT table_name from system_schema.tables WHERE keyspace_name = '" + fromKeyspace + "'",
|
||||
[],
|
||||
{},
|
||||
);
|
||||
for (row of result.rows) {
|
||||
try {
|
||||
const fromTable = fromKeyspace + "." + row.table_name;
|
||||
const toTable = toKeyspace + "." + row.table_name;
|
||||
|
||||
if (ignoreTables.includes(row.table_name)) {
|
||||
console.log(fromTable.padEnd(50) + " | " + "ignored".padEnd(20) + " | ⏺");
|
||||
continue;
|
||||
}
|
||||
|
||||
let fromCount = 0;
|
||||
|
||||
const destColumns = (
|
||||
await client(
|
||||
toClient,
|
||||
"SELECT column_name from system_schema.columns where keyspace_name = '" +
|
||||
toKeyspace +
|
||||
"' and table_name = '" +
|
||||
row.table_name +
|
||||
"'",
|
||||
[],
|
||||
{},
|
||||
)
|
||||
).rows.map(r => r.column_name);
|
||||
|
||||
try {
|
||||
const fromResult = await client(
|
||||
fromClient,
|
||||
"SELECT count(*) from " + fromTable + "",
|
||||
[],
|
||||
{},
|
||||
);
|
||||
fromCount = parseInt(fromResult.rows[0].count);
|
||||
} catch (err) {
|
||||
fromCount = NaN;
|
||||
}
|
||||
|
||||
if (destColumns.length === 0) {
|
||||
if (fromCount > 0)
|
||||
console.log(
|
||||
fromTable.padEnd(50) + " | " + ("not_in_dest" + "/" + fromCount).padEnd(20) + " | ⏺",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const toResult = await client(toClient, "SELECT count(*) from " + toTable + "", [], {});
|
||||
const toCount = parseInt(toResult.rows[0].count);
|
||||
|
||||
console.log(
|
||||
fromTable.padEnd(50) +
|
||||
" | " +
|
||||
(toCount + "/" + fromCount).padEnd(20) +
|
||||
" | " +
|
||||
(toCount >= fromCount ? "✅" : "❌"),
|
||||
);
|
||||
|
||||
if (row.table_name.indexOf("counter") >= 0 || countersTables.includes(row.table_name)) {
|
||||
console.log(
|
||||
fromTable.padEnd(50) + " | " + ("counter_table" + "/" + fromCount).padEnd(20) + " | 🧮",
|
||||
);
|
||||
if (fromCount > toCount || !fromCount || forceUpdateAll) {
|
||||
await new Promise(r => {
|
||||
fromClient.eachRow(
|
||||
"SELECT JSON * from " + fromTable,
|
||||
[],
|
||||
{ prepare: true, fetchSize: 1000 },
|
||||
async function (n, row) {
|
||||
//
|
||||
//TODO handle counters (it is special !)
|
||||
},
|
||||
async function (err, result) {
|
||||
if (result && result.nextPage) {
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
result.nextPage();
|
||||
} else {
|
||||
r();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fromCount > toCount || !fromCount || forceUpdateAll) {
|
||||
await new Promise(r => {
|
||||
fromClient.eachRow(
|
||||
"SELECT JSON * from " + fromTable,
|
||||
[],
|
||||
{ prepare: true, fetchSize: 1000 },
|
||||
async function (n, row) {
|
||||
try {
|
||||
const json = JSON.parse(row["[json]"]);
|
||||
|
||||
//The from table can have additional depreciated fields, we need to remove them
|
||||
const filteredJson = {};
|
||||
for (const col of destColumns) {
|
||||
if (
|
||||
typeof json[col] == "string" &&
|
||||
(json[col] || "").match(
|
||||
/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]+/,
|
||||
)
|
||||
) {
|
||||
json[col] = json[col].split(".")[0];
|
||||
}
|
||||
|
||||
if (specialConversions[fromTable + "." + col]) {
|
||||
json[col] = specialConversions[fromTable + "." + col](json[col]);
|
||||
}
|
||||
|
||||
if (json[col] !== undefined) filteredJson[col] = json[col];
|
||||
}
|
||||
|
||||
await client(
|
||||
toClient,
|
||||
"INSERT INTO " +
|
||||
toTable +
|
||||
" JSON '" +
|
||||
JSON.stringify(filteredJson).replace(/'/g, "'$&") +
|
||||
"'",
|
||||
[],
|
||||
{},
|
||||
);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
},
|
||||
async function (err, result) {
|
||||
if (result && result.nextPage) {
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
result.nextPage();
|
||||
} else {
|
||||
r();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(
|
||||
fromTable.padEnd(50) + " | " + ("error" + "/" + fromCount).padEnd(20) + " | ❌",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
continue;
|
||||
}
|
||||
|
||||
//TODO copy content
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "copy-cluster",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cassandra-driver": "^4.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
yarn.lock
|
||||
@@ -0,0 +1,4 @@
|
||||
Edit the file to your needs
|
||||
|
||||
yarn install
|
||||
node index.js
|
||||
@@ -0,0 +1,98 @@
|
||||
/* eslint-disable */
|
||||
var cassandra = require("cassandra-driver");
|
||||
|
||||
var fromAuthProvider = new cassandra.auth.PlainTextAuthProvider("", "");
|
||||
var fromContactPoints = [""];
|
||||
|
||||
var toAuthProvider = new cassandra.auth.PlainTextAuthProvider("", "");
|
||||
var toContactPoints = [""];
|
||||
|
||||
// -- start process
|
||||
|
||||
var fromClient = new cassandra.Client({
|
||||
localDataCenter: "datacenter1",
|
||||
contactPoints: fromContactPoints,
|
||||
authProvider: fromAuthProvider,
|
||||
keyspace: "tdrive",
|
||||
});
|
||||
|
||||
var toClient = new cassandra.Client({
|
||||
localDataCenter: "datacenter1",
|
||||
contactPoints: toContactPoints,
|
||||
authProvider: toAuthProvider,
|
||||
keyspace: "tdrive",
|
||||
queryOptions: {
|
||||
consistency: cassandra.types.consistencies.quorum,
|
||||
},
|
||||
});
|
||||
|
||||
// -- Get all tables and copy schema
|
||||
|
||||
async function client(origin, query, parameters, options) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
origin.execute(query, [], {}, function (err, result) {
|
||||
if (err) {
|
||||
reject({ err, result });
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Get all threads and then copy all messages in each threads
|
||||
|
||||
let copiedMessages = 0;
|
||||
let copiedThreads = 0;
|
||||
|
||||
(async () => {
|
||||
await new Promise(r => {
|
||||
fromClient.eachRow(
|
||||
"SELECT id from tdrive.threads",
|
||||
[],
|
||||
{ prepare: true, fetchSize: 50 },
|
||||
async function (n, row) {
|
||||
const threadId = row["id"];
|
||||
|
||||
console.log("Threads / Messages :", copiedThreads, "/", copiedMessages);
|
||||
|
||||
copiedThreads++;
|
||||
|
||||
await new Promise(r2 => {
|
||||
fromClient.eachRow(
|
||||
"SELECT JSON * from tdrive.messages where thread_id = ? order by id desc",
|
||||
[threadId],
|
||||
{ prepare: true, fetchSize: 1000 },
|
||||
async function (n, row) {
|
||||
const message = row["[json]"];
|
||||
|
||||
copiedMessages++;
|
||||
|
||||
await toClient.execute(
|
||||
"INSERT INTO tdrive.messages JSON '" + message.replace(/'/g, "'$&") + "'",
|
||||
[],
|
||||
{ prepare: true },
|
||||
);
|
||||
},
|
||||
async function (err, result) {
|
||||
if (result && result.nextPage) {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
result.nextPage();
|
||||
} else {
|
||||
r2();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
async function (err, result) {
|
||||
if (result && result.nextPage) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
result.nextPage();
|
||||
} else {
|
||||
r();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "copy-messages",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cassandra-driver": "^4.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
Edit the file to your needs
|
||||
|
||||
yarn install
|
||||
node index.js
|
||||
@@ -0,0 +1,88 @@
|
||||
/* eslint-disable */
|
||||
var cassandra = require("cassandra-driver");
|
||||
|
||||
var authProvider = new cassandra.auth.PlainTextAuthProvider("", "");
|
||||
var contactPoints = [""];
|
||||
var from = "table_a";
|
||||
var to = "table_b";
|
||||
|
||||
var client = new cassandra.Client({
|
||||
localDataCenter: "datacenter1",
|
||||
contactPoints: contactPoints,
|
||||
authProvider: authProvider,
|
||||
keyspace: "tdrive",
|
||||
});
|
||||
|
||||
//Copy object to other table
|
||||
(async () => {
|
||||
const query = "SELECT * FROM " + from;
|
||||
const parameters = [];
|
||||
|
||||
let count = 0;
|
||||
let max = 0;
|
||||
let min = new Date().getTime();
|
||||
|
||||
let rows = [];
|
||||
|
||||
const options = { prepare: true, fetchSize: 1000 };
|
||||
|
||||
await new Promise(r => {
|
||||
client.eachRow(
|
||||
query,
|
||||
parameters,
|
||||
options,
|
||||
function (n, row) {
|
||||
min = Math.min(parseInt(row.created_at.toString()), min);
|
||||
max = Math.max(parseInt(row.created_at.toString()), max);
|
||||
count++;
|
||||
if (count % 100 == 0) {
|
||||
console.log(count);
|
||||
}
|
||||
rows.push(row);
|
||||
},
|
||||
function (err, result) {
|
||||
if (result && result.nextPage) {
|
||||
result.nextPage();
|
||||
} else {
|
||||
r();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
console.log("Downloaded ", count, " rows of ", from, ". Starting copy...");
|
||||
|
||||
let copyCount = 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
copyCount++;
|
||||
if (copyCount % 100 == 0) {
|
||||
console.log(copyCount, "of", count);
|
||||
}
|
||||
|
||||
const row = rows[i];
|
||||
|
||||
await new Promise(r => {
|
||||
let query =
|
||||
"UPDATE " +
|
||||
to +
|
||||
" SET answers=?, created_at=?, created_by=?, last_activity=?, participants=?, updated_at=? WHERE id=?";
|
||||
client.execute(
|
||||
query,
|
||||
[
|
||||
row.answers,
|
||||
row.created_at,
|
||||
row.created_by,
|
||||
row.last_activity,
|
||||
row.participants,
|
||||
row.updated_at,
|
||||
row.id,
|
||||
],
|
||||
() => {
|
||||
r();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
console.log("Ended with ", count, "threads");
|
||||
})();
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "copy-table",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
Edit the file to your needs
|
||||
|
||||
yarn install
|
||||
node index.js
|
||||
@@ -0,0 +1,96 @@
|
||||
/* eslint-disable */
|
||||
var cassandra = require("cassandra-driver");
|
||||
|
||||
var authProvider = new cassandra.auth.PlainTextAuthProvider("", "");
|
||||
var contactPoints = [""];
|
||||
|
||||
var client = new cassandra.Client({
|
||||
localDataCenter: "datacenter1",
|
||||
contactPoints: contactPoints,
|
||||
authProvider: authProvider,
|
||||
keyspace: "tdrive",
|
||||
});
|
||||
|
||||
var threadsTable = "threads";
|
||||
var messagesTable = "messages";
|
||||
|
||||
//Copy object to other table
|
||||
(async () => {
|
||||
const query = "SELECT * FROM " + threadsTable + " where answers>1000 allow filtering";
|
||||
const parameters = [];
|
||||
const options = { prepare: true, fetchSize: 1000 };
|
||||
|
||||
let count = 0;
|
||||
|
||||
let threads = {};
|
||||
|
||||
await new Promise(r => {
|
||||
client.eachRow(
|
||||
query,
|
||||
parameters,
|
||||
options,
|
||||
function (n, row) {
|
||||
threads[row.id] ||= { answers: 0, id: row.id };
|
||||
count++;
|
||||
},
|
||||
function (err, result) {
|
||||
if (result && result.nextPage) {
|
||||
result.nextPage();
|
||||
} else {
|
||||
r();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
console.log(
|
||||
"Downloaded ",
|
||||
Object.keys(threads).length,
|
||||
" threads of ",
|
||||
messagesTable,
|
||||
". Starting copy...",
|
||||
);
|
||||
|
||||
for (var key of Object.keys(threads)) {
|
||||
await new Promise(r => {
|
||||
const query = "SELECT id FROM " + messagesTable + " where thread_id=" + key;
|
||||
const parameters = [];
|
||||
const options = { prepare: true, fetchSize: 1000 };
|
||||
client.eachRow(
|
||||
query,
|
||||
parameters,
|
||||
options,
|
||||
function (n, row) {
|
||||
threads[key].answers++;
|
||||
},
|
||||
function (err, result) {
|
||||
if (result && result.nextPage) {
|
||||
result.nextPage();
|
||||
} else {
|
||||
r();
|
||||
}
|
||||
},
|
||||
);
|
||||
r();
|
||||
});
|
||||
}
|
||||
|
||||
var copyCount = 0;
|
||||
for (var key of Object.keys(threads)) {
|
||||
copyCount++;
|
||||
if (copyCount % 100 == 0) {
|
||||
console.log(copyCount, "of", Object.keys(threads));
|
||||
}
|
||||
|
||||
const row = threads[key];
|
||||
|
||||
await new Promise(r => {
|
||||
let query = "UPDATE " + threadsTable + " SET answers=? WHERE id=?";
|
||||
console.log(query, [row.answers, row.id + ""]);
|
||||
//client.execute(query, [row.answers, row.id], () => {
|
||||
// r();
|
||||
//});
|
||||
});
|
||||
}
|
||||
console.log("Ended with ", Object.keys(threads), "threads");
|
||||
})();
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "repair-threads",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cassandra-driver": "^4.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import config from "config";
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,50 @@
|
||||
import legacy from "./legacy";
|
||||
import v1 from "./v1";
|
||||
import v2 from "./v2";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
export type CryptoResult = {
|
||||
/**
|
||||
* Encrypted | Decrypted data if done, original data if not done due to error or not encrypted data to decrypt
|
||||
*/
|
||||
data: any;
|
||||
/**
|
||||
* Is there an error while processing the input data?
|
||||
*/
|
||||
err?: Error;
|
||||
/**
|
||||
* Did we encrypted | decrypted the data? true if yes.
|
||||
*/
|
||||
done?: boolean;
|
||||
};
|
||||
|
||||
export function decrypt(data: string, encryptionKey: string): CryptoResult {
|
||||
let result = v2.decrypt(data, encryptionKey);
|
||||
if (result.done) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = v1.decrypt(data, encryptionKey);
|
||||
if (result.done) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = legacy.decrypt(data, encryptionKey);
|
||||
if (result.done) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function md5(value: string): string {
|
||||
return createHash("md5").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function encrypt(
|
||||
value: any,
|
||||
encryptionKey: any,
|
||||
options: { disableSalts?: boolean } = {},
|
||||
): CryptoResult {
|
||||
return v2.encrypt(value, encryptionKey, options);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createDecipheriv } from "crypto";
|
||||
import { CryptoResult } from ".";
|
||||
|
||||
const PREFIX = "encrypted_";
|
||||
|
||||
export default {
|
||||
decrypt,
|
||||
encrypt,
|
||||
};
|
||||
|
||||
function decrypt(data: string, encryptionKey: string): CryptoResult {
|
||||
if (!data || !data.startsWith(PREFIX)) {
|
||||
return {
|
||||
data,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
const toDecode = data.substr(PREFIX.length);
|
||||
const encryptedArray = toDecode.split("_");
|
||||
|
||||
// If array length is 1, the value has not been salted and iv is the default one
|
||||
const encrypted = Buffer.from(encryptedArray[0], "base64");
|
||||
const salt = encryptedArray[1]
|
||||
? Buffer.from(encryptedArray[1], "utf-8")
|
||||
: Buffer.from("", "utf-8");
|
||||
const iv =
|
||||
encryptedArray.length >= 3
|
||||
? Buffer.from(encryptedArray[2], "base64")
|
||||
: Buffer.from("twake_constantiv", "utf-8");
|
||||
const password = Buffer.concat([Buffer.from(encryptionKey, "hex"), salt], 32);
|
||||
const decipher = createDecipheriv("AES-256-CBC", password, iv);
|
||||
|
||||
return {
|
||||
data: Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf-8"),
|
||||
done: true,
|
||||
};
|
||||
}
|
||||
|
||||
function encrypt(data: any): CryptoResult {
|
||||
return {
|
||||
data,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
||||
import { CryptoResult } from ".";
|
||||
|
||||
export default {
|
||||
encrypt,
|
||||
decrypt,
|
||||
};
|
||||
|
||||
function encrypt(
|
||||
data: any,
|
||||
encryptionKey: any,
|
||||
options: { disableSalts?: boolean } = {},
|
||||
): CryptoResult {
|
||||
const key = createHash("sha256").update(String(encryptionKey)).digest("base64").substr(0, 32);
|
||||
try {
|
||||
const iv = options.disableSalts ? "0000000000000000" : randomBytes(16);
|
||||
const cipher = createCipheriv("aes-256-cbc", key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(JSON.stringify(data)), cipher.final()]).toString(
|
||||
"hex",
|
||||
);
|
||||
return {
|
||||
data: `${iv.toString("hex")}:${encrypted}`,
|
||||
done: true,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
err,
|
||||
data,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function decrypt(data: string, encryptionKey: any): CryptoResult {
|
||||
const key = createHash("sha256").update(String(encryptionKey)).digest("base64").substr(0, 32);
|
||||
|
||||
const encryptedArray = data.split(":");
|
||||
|
||||
if (!encryptedArray.length || encryptedArray.length !== 2) {
|
||||
return {
|
||||
data,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
let iv: Buffer | string = Buffer.from(encryptedArray[0], "hex");
|
||||
if (encryptedArray[0] === "0000000000000000") {
|
||||
iv = "0000000000000000";
|
||||
}
|
||||
|
||||
try {
|
||||
const encrypted = Buffer.from(encryptedArray[1], "hex");
|
||||
const decipher = createDecipheriv("aes-256-cbc", key, iv);
|
||||
const decrypt = JSON.parse(
|
||||
Buffer.concat([decipher.update(encrypted), decipher.final()]).toString(),
|
||||
);
|
||||
return {
|
||||
data: decrypt,
|
||||
done: true,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
data,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
||||
import { CryptoResult } from ".";
|
||||
|
||||
export default {
|
||||
encrypt,
|
||||
decrypt,
|
||||
};
|
||||
|
||||
function encrypt(
|
||||
data: any,
|
||||
encryptionKey: any,
|
||||
options: { disableSalts?: boolean } = {},
|
||||
): CryptoResult {
|
||||
const key = createHash("sha256").update(String(encryptionKey)).digest("hex").substr(0, 32);
|
||||
try {
|
||||
const iv = options.disableSalts ? "0000000000000000" : randomBytes(16);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const encrypted =
|
||||
cipher.update(JSON.stringify(data), "utf8", "base64") + cipher.final("base64");
|
||||
|
||||
return {
|
||||
data: `${iv.toString("base64")}:${cipher.getAuthTag().toString("base64")}:${encrypted}`,
|
||||
done: true,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
err,
|
||||
data,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function decrypt(data: string, encryptionKey: any): CryptoResult {
|
||||
const key = createHash("sha256").update(String(encryptionKey)).digest("hex").substr(0, 32);
|
||||
|
||||
const encryptedArray = data.split(":");
|
||||
|
||||
if (!encryptedArray.length || encryptedArray.length !== 3) {
|
||||
return {
|
||||
data,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
let iv: Buffer | string = Buffer.from(encryptedArray[0], "base64");
|
||||
if (encryptedArray[0] === "0000000000000000") {
|
||||
iv = "0000000000000000";
|
||||
}
|
||||
|
||||
try {
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(Buffer.from(encryptedArray[1], "base64"));
|
||||
|
||||
const encrypted = encryptedArray[2];
|
||||
let str = decipher.update(encrypted, "base64", "utf8");
|
||||
str += decipher.final("utf8");
|
||||
const decrypt = JSON.parse(str);
|
||||
|
||||
return {
|
||||
data: decrypt,
|
||||
done: true,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
data,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { TdriveServiceOptions } from "./service-options";
|
||||
import { TdriveServiceConfiguration } from "./service-configuration";
|
||||
|
||||
export class TdriveAppConfiguration extends TdriveServiceOptions<TdriveServiceConfiguration> {
|
||||
services: Array<string>;
|
||||
servicesPath: string;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type Class = { new (...args: any[]): any };
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { TdriveService } from "./service";
|
||||
import { TdriveServiceProvider } from "./service-provider";
|
||||
import { ServiceDefinition } from "./service-definition";
|
||||
import { TdriveServiceState } from "./service-state";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export class TdriveComponent {
|
||||
instance: TdriveService<TdriveServiceProvider>;
|
||||
components: Array<TdriveComponent> = new Array<TdriveComponent>();
|
||||
|
||||
constructor(public name: string, private serviceDefinition: ServiceDefinition) {}
|
||||
|
||||
getServiceDefinition(): ServiceDefinition {
|
||||
return this.serviceDefinition;
|
||||
}
|
||||
|
||||
setServiceInstance(instance: TdriveService<TdriveServiceProvider>): void {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
getServiceInstance(): TdriveService<TdriveServiceProvider> {
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
addDependency(component: TdriveComponent): void {
|
||||
this.components.push(component);
|
||||
}
|
||||
|
||||
getStateTree(): string {
|
||||
return `${this.name}(${this.instance.state.value}) => {${this.components
|
||||
.map(component => component.getStateTree())
|
||||
.join(",")}}`;
|
||||
}
|
||||
|
||||
async switchToState(
|
||||
state: TdriveServiceState.Initialized | TdriveServiceState.Started | TdriveServiceState.Stopped,
|
||||
recursionDepth?: number,
|
||||
): Promise<void> {
|
||||
if (recursionDepth > 10) {
|
||||
logger.error("Maximum recursion depth exceeded (will exit process)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const states: BehaviorSubject<TdriveServiceState>[] = this.components.map(
|
||||
component => component.instance.state,
|
||||
);
|
||||
|
||||
if (states.length) {
|
||||
for (const component of this.components) {
|
||||
await component.switchToState(state, (recursionDepth || 0) + 1);
|
||||
}
|
||||
logger.info(`Children of ${this.name} are all in ${state} state`);
|
||||
logger.info(this.getStateTree());
|
||||
} else {
|
||||
logger.info(`${this.name} does not have children`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function _switchServiceToState(service: TdriveService<any>) {
|
||||
state === TdriveServiceState.Initialized && (await service.init());
|
||||
state === TdriveServiceState.Started && (await service.start());
|
||||
state === TdriveServiceState.Stopped && (await service.stop());
|
||||
}
|
||||
await _switchServiceToState(this.instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const PREFIX_METADATA = "service:prefix";
|
||||
export const CONSUMES_METADATA = "service:consumes";
|
||||
export const SERVICENAME_METADATA = "service:name";
|
||||
@@ -0,0 +1,73 @@
|
||||
import { TdriveAppConfiguration } from "./application-configuration";
|
||||
import { TdriveComponent } from "./component";
|
||||
import { TdriveContext } from "./context";
|
||||
import { TdriveServiceProvider } from "./service-provider";
|
||||
import { TdriveService } from "./service";
|
||||
import { TdriveServiceState } from "./service-state";
|
||||
import * as ComponentUtils from "../utils/component-utils";
|
||||
|
||||
/**
|
||||
* A container contains components. It provides methods to manage and to retrieve them.
|
||||
*/
|
||||
export abstract class TdriveContainer
|
||||
extends TdriveService<TdriveServiceProvider>
|
||||
implements TdriveContext
|
||||
{
|
||||
private components: Map<string, TdriveComponent>;
|
||||
name = "TdriveContainer";
|
||||
|
||||
constructor(protected options?: TdriveAppConfiguration) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
abstract loadComponents(): Promise<Map<string, TdriveComponent>>;
|
||||
|
||||
abstract loadComponent(name: string): Promise<TdriveComponent>;
|
||||
|
||||
getProvider<T extends TdriveServiceProvider>(name: string): T {
|
||||
const service = this.components.get(name)?.getServiceInstance();
|
||||
|
||||
if (!service) {
|
||||
throw new Error(`Service "${name}" not found`);
|
||||
}
|
||||
|
||||
return service.api() as T;
|
||||
}
|
||||
|
||||
async doInit(): Promise<this> {
|
||||
this.components = await this.loadComponents();
|
||||
await ComponentUtils.buildDependenciesTree(this.components, async (name: string) => {
|
||||
const component = await this.loadComponent(name);
|
||||
if (component) this.components.set(name, component);
|
||||
return component;
|
||||
});
|
||||
|
||||
await this.launchInit();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
protected async launchInit(): Promise<this> {
|
||||
await this.switchToState(TdriveServiceState.Initialized);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async doStart(): Promise<this> {
|
||||
await this.switchToState(TdriveServiceState.Started);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async doStop(): Promise<this> {
|
||||
await this.switchToState(TdriveServiceState.Stopped);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
protected async switchToState(
|
||||
state: TdriveServiceState.Started | TdriveServiceState.Initialized | TdriveServiceState.Stopped,
|
||||
): Promise<void> {
|
||||
await ComponentUtils.switchComponentsToState(this.components, state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TdriveServiceProvider } from "./service-provider";
|
||||
|
||||
export interface TdriveContext {
|
||||
getProvider<T extends TdriveServiceProvider>(name: string): T;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { User } from "../../../../utils/types";
|
||||
|
||||
export class ContextualizedTarget {
|
||||
context?: ExecutionContext;
|
||||
readonly operation: OperationType;
|
||||
}
|
||||
|
||||
export class EntityTarget<Entity> extends ContextualizedTarget {
|
||||
/**
|
||||
*
|
||||
* @param type type of entity
|
||||
* @param entity the entity itself
|
||||
*/
|
||||
constructor(readonly type: string, readonly entity: Entity) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateResult<Entity> extends EntityTarget<Entity> {
|
||||
readonly operation = OperationType.UPDATE;
|
||||
|
||||
/**
|
||||
* Result sent back by the underlying database
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
raw?: any;
|
||||
|
||||
/**
|
||||
* Number of rows affected by the update.
|
||||
*/
|
||||
affected?: number;
|
||||
}
|
||||
|
||||
export class CreateResult<Entity> extends EntityTarget<Entity> {
|
||||
readonly operation = OperationType.CREATE;
|
||||
|
||||
/**
|
||||
* Result sent back by the underlying database
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
raw?: any;
|
||||
}
|
||||
|
||||
export class SaveResult<Entity> extends EntityTarget<Entity> {
|
||||
/**
|
||||
* Result sent back by the underlying database
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
raw?: any;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param type Type of entity
|
||||
* @param entity The entity itself
|
||||
* @param operation Save can be for a "create", an "update" or "exists" when resource already exists
|
||||
*/
|
||||
constructor(
|
||||
readonly type: string,
|
||||
readonly entity: Entity,
|
||||
readonly operation: OperationType.UPDATE | OperationType.CREATE | OperationType.EXISTS,
|
||||
) {
|
||||
super(type, entity);
|
||||
}
|
||||
}
|
||||
|
||||
export class DeleteResult<Entity> extends EntityTarget<Entity> {
|
||||
readonly operation = OperationType.DELETE;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param type type of entity
|
||||
* @param entity the entity itself
|
||||
* @param deleted the entity has been deleted or not
|
||||
*/
|
||||
constructor(readonly type: string, readonly entity: Entity, readonly deleted: boolean) {
|
||||
super(type, entity);
|
||||
}
|
||||
}
|
||||
|
||||
export class ListResult<Entity> extends ContextualizedTarget implements Paginable {
|
||||
// next page token
|
||||
page_token: string;
|
||||
|
||||
constructor(readonly type: string, protected entities: Entity[], readonly nextPage?: Paginable) {
|
||||
super();
|
||||
this.page_token = nextPage?.page_token;
|
||||
}
|
||||
|
||||
mapEntities(mapper: <T extends Entity>(entity: Entity) => T): void {
|
||||
this.entities = this.entities.map(entity => mapper(entity));
|
||||
}
|
||||
|
||||
filterEntities(filter: (entity: Entity) => boolean): void {
|
||||
this.entities = this.entities.filter(entity => filter(entity));
|
||||
}
|
||||
|
||||
getEntities(): Entity[] {
|
||||
return this.entities || [];
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.getEntities().length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
export enum OperationType {
|
||||
CREATE = "create",
|
||||
UPDATE = "update",
|
||||
SAVE = "save",
|
||||
DELETE = "delete",
|
||||
EXISTS = "exists",
|
||||
}
|
||||
|
||||
export declare type EntityOperationResult<Entity> =
|
||||
| CreateResult<Entity>
|
||||
| UpdateResult<Entity>
|
||||
| SaveResult<Entity>
|
||||
| DeleteResult<Entity>;
|
||||
|
||||
export interface ExecutionContext {
|
||||
user: User;
|
||||
reqId?: string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
transport?: "http" | "ws";
|
||||
}
|
||||
|
||||
export class CrudException extends Error {
|
||||
constructor(readonly details: string, readonly status: number) {
|
||||
super();
|
||||
this.message = details;
|
||||
}
|
||||
|
||||
static badRequest(details: string): CrudException {
|
||||
return new CrudException(details, 400);
|
||||
}
|
||||
|
||||
static notFound(details: string): CrudException {
|
||||
return new CrudException(details, 404);
|
||||
}
|
||||
static forbidden(details: string): CrudException {
|
||||
return new CrudException(details, 403);
|
||||
}
|
||||
|
||||
static notImplemented(details: string): CrudException {
|
||||
return new CrudException(details, 501);
|
||||
}
|
||||
|
||||
static badGateway(details: string): CrudException {
|
||||
return new CrudException(details, 502);
|
||||
}
|
||||
}
|
||||
|
||||
export interface Paginable {
|
||||
page_token?: string;
|
||||
limitStr?: string;
|
||||
reversed?: boolean;
|
||||
}
|
||||
|
||||
export class Pagination implements Paginable {
|
||||
reversed?: boolean;
|
||||
constructor(readonly page_token?: string, readonly limitStr = "100", reversed = false) {
|
||||
this.reversed = reversed;
|
||||
}
|
||||
|
||||
public static fromPaginable(p: Paginable) {
|
||||
return new Pagination(p.page_token, p.limitStr, p.reversed);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CRUDService<Entity, PrimaryKey, Context extends ExecutionContext> {
|
||||
/**
|
||||
* Creates a resource
|
||||
*
|
||||
* @param item
|
||||
* @param context
|
||||
*/
|
||||
create?(item: Entity, context?: Context): Promise<CreateResult<Entity>>;
|
||||
|
||||
/**
|
||||
* Get a resource
|
||||
*
|
||||
* @param pk
|
||||
* @param context
|
||||
*/
|
||||
get(pk: PrimaryKey, context?: Context): Promise<Entity>;
|
||||
|
||||
/**
|
||||
* Update a resource
|
||||
*
|
||||
* @param pk
|
||||
* @param item
|
||||
* @param context
|
||||
*/
|
||||
update?(
|
||||
pk: PrimaryKey,
|
||||
item: Entity,
|
||||
context?: Context /* TODO: Options */,
|
||||
): Promise<UpdateResult<Entity>>;
|
||||
|
||||
/**
|
||||
* Save a resource.
|
||||
* If the resource exists, it is updated, if it does not exists, it is created.
|
||||
*
|
||||
* @param itemOrItems
|
||||
* @param options
|
||||
* @param context
|
||||
*/
|
||||
save?<SaveOptions>(
|
||||
item: Entity,
|
||||
options?: SaveOptions,
|
||||
context?: Context,
|
||||
): Promise<SaveResult<Entity>>;
|
||||
|
||||
/**
|
||||
* Delete a resource
|
||||
*
|
||||
* @param pk
|
||||
* @param context
|
||||
*/
|
||||
delete(pk: PrimaryKey, context?: Context): Promise<DeleteResult<Entity>>;
|
||||
|
||||
/**
|
||||
* List a resource
|
||||
*
|
||||
* @param context
|
||||
*/
|
||||
list<ListOptions>(
|
||||
pagination: Paginable,
|
||||
options?: ListOptions,
|
||||
context?: Context /* TODO: Options */,
|
||||
): Promise<ListResult<Entity>>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export * from "./application-configuration";
|
||||
export * from "./class";
|
||||
export * from "./component";
|
||||
export * from "./constants";
|
||||
export * from "./context";
|
||||
export * from "./container";
|
||||
export * from "./lifecycle";
|
||||
export * from "./service-configuration";
|
||||
export * from "./service-definition";
|
||||
export * from "./service-interface";
|
||||
export * from "./service-options";
|
||||
export * from "./service-provider";
|
||||
export * from "./service-state";
|
||||
export * from "./service";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { TdriveContext } from "./context";
|
||||
|
||||
export interface Initializable {
|
||||
// TODO: Flag to tell if a service which fails to initialize must break all
|
||||
init?(context?: TdriveContext): Promise<this>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface TdriveServiceConfiguration {
|
||||
get<T>(name?: string, defaultValue?: T): T;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Class } from "./class";
|
||||
|
||||
export interface ServiceDefinition {
|
||||
name: string;
|
||||
clazz: Class;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { TdriveServiceProvider } from "./service-provider";
|
||||
|
||||
export interface TdriveServiceInterface<T extends TdriveServiceProvider> {
|
||||
doInit(): Promise<this>;
|
||||
doStart(): Promise<this>;
|
||||
doStop(): Promise<this>;
|
||||
api(): T;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class TdriveServiceOptions<TdriveServiceConfiguration> {
|
||||
name?: string;
|
||||
// TODO: configuration is abstract and comes from all others
|
||||
configuration?: TdriveServiceConfiguration;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface TdriveServiceProvider {
|
||||
version: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export enum TdriveServiceState {
|
||||
Ready = "READY",
|
||||
Initializing = "INITIALIZING",
|
||||
Initialized = "INITIALIZED",
|
||||
Starting = "STARTING",
|
||||
Started = "STARTED",
|
||||
Stopping = "STOPPING",
|
||||
Stopped = "STOPPED",
|
||||
Errored = "ERRORED",
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { TdriveServiceInterface } from "./service-interface";
|
||||
import { TdriveServiceProvider } from "./service-provider";
|
||||
import { TdriveServiceState } from "./service-state";
|
||||
import { TdriveServiceConfiguration } from "./service-configuration";
|
||||
import { TdriveContext } from "./context";
|
||||
import { TdriveServiceOptions } from "./service-options";
|
||||
import { CONSUMES_METADATA, PREFIX_METADATA } from "./constants";
|
||||
import { getLogger, logger } from "../logger";
|
||||
import { TdriveLogger } from "..";
|
||||
|
||||
const pendingServices: any = {};
|
||||
|
||||
export abstract class TdriveService<T extends TdriveServiceProvider>
|
||||
implements TdriveServiceInterface<TdriveServiceProvider>
|
||||
{
|
||||
state: BehaviorSubject<TdriveServiceState>;
|
||||
readonly name: string;
|
||||
protected readonly configuration: TdriveServiceConfiguration;
|
||||
context: TdriveContext;
|
||||
logger: TdriveLogger;
|
||||
|
||||
constructor(protected options?: TdriveServiceOptions<TdriveServiceConfiguration>) {
|
||||
this.state = new BehaviorSubject<TdriveServiceState>(TdriveServiceState.Ready);
|
||||
// REMOVE ME, we should import config from framework folder instead
|
||||
this.configuration = options?.configuration;
|
||||
this.logger = getLogger(`core.platform.services.${this.name}Service`);
|
||||
}
|
||||
|
||||
abstract api(): T;
|
||||
|
||||
public get prefix(): string {
|
||||
return Reflect.getMetadata(PREFIX_METADATA, this) || "/";
|
||||
}
|
||||
|
||||
getConsumes(): Array<string> {
|
||||
return Reflect.getMetadata(CONSUMES_METADATA, this) || [];
|
||||
}
|
||||
|
||||
async init(): Promise<this> {
|
||||
if (this.state.value !== TdriveServiceState.Ready) {
|
||||
logger.info("Service %s is already initialized", this.name);
|
||||
return this;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info("Initializing service %s", this.name);
|
||||
pendingServices[this.name] = true;
|
||||
this.state.next(TdriveServiceState.Initializing);
|
||||
await this.doInit();
|
||||
this.state.next(TdriveServiceState.Initialized);
|
||||
logger.info("Service %s is initialized", this.name);
|
||||
delete pendingServices[this.name];
|
||||
logger.info("Pending services: %s", JSON.stringify(Object.keys(pendingServices)));
|
||||
return this;
|
||||
} catch (err) {
|
||||
logger.error("Error while initializing service %s", this.name);
|
||||
logger.error(err);
|
||||
this.state.error(new Error(`Error while initializing service ${this.name}`));
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async doInit(): Promise<this> {
|
||||
return this;
|
||||
}
|
||||
|
||||
async doStart(): Promise<this> {
|
||||
return this;
|
||||
}
|
||||
|
||||
async start(): Promise<this> {
|
||||
if (
|
||||
this.state.value === TdriveServiceState.Starting ||
|
||||
this.state.value === TdriveServiceState.Started
|
||||
) {
|
||||
logger.info("Service %s is already started", this.name);
|
||||
return this;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info("Starting service %s", this.name);
|
||||
this.state.next(TdriveServiceState.Starting);
|
||||
await this.doStart();
|
||||
this.state.next(TdriveServiceState.Started);
|
||||
logger.info("Service %s is started", this.name);
|
||||
|
||||
return this;
|
||||
} catch (err) {
|
||||
logger.error("Error while starting service %s", this.name, err);
|
||||
logger.error(err);
|
||||
this.state.error(new Error(`Error while starting service ${this.name}`));
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<this> {
|
||||
if (
|
||||
this.state.value === TdriveServiceState.Stopping ||
|
||||
this.state.value === TdriveServiceState.Stopped
|
||||
) {
|
||||
logger.info("Service %s is already stopped", this.name);
|
||||
return this;
|
||||
}
|
||||
|
||||
if (this.state.value !== TdriveServiceState.Started) {
|
||||
logger.info("Service %s can not be stopped until started", this.name);
|
||||
return this;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info("Stopping service %s", this.name);
|
||||
this.state.next(TdriveServiceState.Stopping);
|
||||
await this.doStop();
|
||||
this.state.next(TdriveServiceState.Stopped);
|
||||
logger.info("Service %s is stopped", this.name);
|
||||
|
||||
return this;
|
||||
} catch (err) {
|
||||
logger.error("Error while stopping service %s", this.name, err);
|
||||
logger.error(err);
|
||||
this.state.error(new Error(`Error while stopping service ${this.name}`));
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async doStop(): Promise<this> {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { PREFIX_METADATA } from "./constants";
|
||||
import { FastifyInstance, FastifyPluginCallback } from "fastify";
|
||||
|
||||
import gr from "../../../../services/global-resolver";
|
||||
|
||||
export abstract class AbstractWebService {
|
||||
abstract routes(fastify: FastifyInstance): void;
|
||||
|
||||
public get prefix(): string {
|
||||
return Reflect.getMetadata(PREFIX_METADATA, this) || "/";
|
||||
}
|
||||
|
||||
public process(): void {
|
||||
const wrapper: FastifyPluginCallback = (fastify: FastifyInstance, options, next) => {
|
||||
this.routes(fastify);
|
||||
next();
|
||||
};
|
||||
|
||||
gr.fastify.register(wrapper, { prefix: this.prefix });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { IConfig } from "config";
|
||||
import configuration from "../../config";
|
||||
import { TdriveServiceConfiguration } from "./api";
|
||||
export class Configuration implements TdriveServiceConfiguration {
|
||||
configuration: IConfig;
|
||||
serviceConfiguration: IConfig;
|
||||
|
||||
constructor(path: string) {
|
||||
try {
|
||||
this.serviceConfiguration = configuration.get(path);
|
||||
} catch {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
|
||||
get<T>(name?: string, defaultValue?: T): T {
|
||||
let value: T;
|
||||
|
||||
try {
|
||||
value = this.serviceConfiguration as unknown as T;
|
||||
if (name) {
|
||||
value =
|
||||
this.serviceConfiguration &&
|
||||
(this.serviceConfiguration.get(name) || configuration.get(name));
|
||||
}
|
||||
} catch {
|
||||
value = defaultValue || null;
|
||||
} finally {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { CONSUMES_METADATA } from "../api";
|
||||
|
||||
export function Consumes(services: string[] = []): ClassDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Function): void {
|
||||
Reflect.defineMetadata(CONSUMES_METADATA, services, target.prototype);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./consumes";
|
||||
export * from "./prefix";
|
||||
export * from "./service-name";
|
||||
export * from "./realtime";
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PREFIX_METADATA } from "../api";
|
||||
|
||||
export function Prefix(prefix: string = "/"): ClassDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Function): void {
|
||||
Reflect.defineMetadata(PREFIX_METADATA, prefix, target.prototype);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { CreateResult } from "../../api/crud-service";
|
||||
import { RealtimeEntityEvent, RealtimeEntityActionType } from "../../../services/realtime/types";
|
||||
import { websocketEventBus } from "../../../services/realtime/bus";
|
||||
import { getRealtimeRecipients, getRoom, RealtimeRecipients } from ".";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param path the path to push the notification to
|
||||
* @param resourcePath the path of the resource itself
|
||||
*/
|
||||
export function RealtimeCreated<T>(recipients: RealtimeRecipients<T>): MethodDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Object, propertyKey: string, descriptor: PropertyDescriptor): void {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
const result: CreateResult<T> = await originalMethod.apply(this, args);
|
||||
// context should always be the last arg
|
||||
const context = args && args[args.length - 1];
|
||||
|
||||
if (!(result instanceof CreateResult)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
getRealtimeRecipients(recipients, result.entity, context).forEach(
|
||||
({ room, path, resource }) => {
|
||||
websocketEventBus.publish<T>(RealtimeEntityActionType.Created, {
|
||||
type: result.type,
|
||||
room: getRoom(room, result, context),
|
||||
resourcePath: path || "/",
|
||||
entity: resource,
|
||||
result,
|
||||
} as RealtimeEntityEvent<T>);
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { getRealtimeRecipients, getRoom, RealtimeRecipients } from "..";
|
||||
import { DeleteResult } from "../../api/crud-service";
|
||||
import { RealtimeEntityEvent, RealtimeEntityActionType } from "../../../services/realtime/types";
|
||||
import { websocketEventBus } from "../../../services/realtime/bus";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param path the path to push the notification to
|
||||
* @param resourcePath the path of the resource itself
|
||||
*/
|
||||
export function RealtimeDeleted<T>(recipients: RealtimeRecipients<T>): MethodDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Object, propertyKey: string, descriptor: PropertyDescriptor): void {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
const result: DeleteResult<T> = await originalMethod.apply(this, args);
|
||||
const context = args && args[args.length - 1];
|
||||
|
||||
if (!(result instanceof DeleteResult)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.deleted)
|
||||
getRealtimeRecipients(recipients, result.entity, context).forEach(
|
||||
({ room, path, resource }) => {
|
||||
websocketEventBus.publish<T>(RealtimeEntityActionType.Deleted, {
|
||||
type: result.type,
|
||||
room: getRoom(room, result, context),
|
||||
resourcePath: path,
|
||||
entity: resource,
|
||||
result,
|
||||
} as RealtimeEntityEvent<T>);
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ResourcePath } from "../../../../../core/platform/services/realtime/types";
|
||||
import { EntityTarget, ExecutionContext } from "../../api/crud-service";
|
||||
|
||||
export * from "./created";
|
||||
export * from "./deleted";
|
||||
export * from "./updated";
|
||||
export * from "./saved";
|
||||
|
||||
export type RealtimeRecipients<T> =
|
||||
| RealtimeRecipient<T>
|
||||
| RealtimeRecipient<T>[]
|
||||
| ((type: T, context?: ExecutionContext) => RealtimeRecipient<T>[] | RealtimeRecipient<T>);
|
||||
|
||||
export type RealtimeRecipient<T> = {
|
||||
room: RealtimePath<T>;
|
||||
path?: string;
|
||||
resource?: any | T;
|
||||
};
|
||||
|
||||
export function getRealtimeRecipients<T>(
|
||||
recipients: RealtimeRecipients<T>,
|
||||
type: T,
|
||||
context?: ExecutionContext,
|
||||
): RealtimeRecipient<T>[] {
|
||||
if (typeof recipients === "function") recipients = recipients(type, context);
|
||||
if (!recipients) return [];
|
||||
if (!(recipients as RealtimeRecipient<T>[]).length)
|
||||
recipients = [recipients as RealtimeRecipient<T>];
|
||||
|
||||
return (recipients as RealtimeRecipient<T>[]).map(recipient => {
|
||||
return {
|
||||
resource: recipient.resource || type,
|
||||
path: recipient.path || "/",
|
||||
room: recipient.room,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type RealtimePath<T> = string | ResourcePath | ResourcePathResolver<T>;
|
||||
export interface ResourcePathResolver<T> {
|
||||
(type: T, context?: ExecutionContext): ResourcePath;
|
||||
}
|
||||
export interface PathResolver<T> {
|
||||
(type: T, context?: ExecutionContext): string;
|
||||
}
|
||||
|
||||
export type RealtimeEntity<T> = null | ((type: T, context?: ExecutionContext) => T);
|
||||
|
||||
export function getRoom<T>(
|
||||
path: RealtimePath<T> = ResourcePath.default(),
|
||||
entity: EntityTarget<T>,
|
||||
context: ExecutionContext,
|
||||
): ResourcePath {
|
||||
if (typeof path === "string") {
|
||||
return ResourcePath.get(path);
|
||||
}
|
||||
|
||||
return typeof path === "function" ? path(entity.entity, context) : path;
|
||||
}
|
||||
|
||||
export function getPath<T>(
|
||||
path: string | PathResolver<T> = "/",
|
||||
entity: EntityTarget<T>,
|
||||
context: ExecutionContext,
|
||||
): string {
|
||||
return typeof path === "function" ? path(entity.entity, context) : path;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { getRealtimeRecipients, getRoom, RealtimeRecipients } from "..";
|
||||
import { SaveResult } from "../../api/crud-service";
|
||||
import { RealtimeEntityEvent, RealtimeEntityActionType } from "../../../services/realtime/types";
|
||||
import { websocketEventBus } from "../../../services/realtime/bus";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param path the path to push the notification to
|
||||
* @param resourcePath the path of the resource itself
|
||||
*/
|
||||
export function RealtimeSaved<T>(recipients: RealtimeRecipients<T>): MethodDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Object, propertyKey: string, descriptor: PropertyDescriptor): void {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
const result: SaveResult<T> = await originalMethod.apply(this, args);
|
||||
// context should always be the last arg
|
||||
const context = args && args[args.length - 1];
|
||||
|
||||
if (!(result instanceof SaveResult)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
getRealtimeRecipients(recipients, result.entity, context).forEach(
|
||||
({ room, path, resource }) => {
|
||||
websocketEventBus.publish<T>(RealtimeEntityActionType.Saved, {
|
||||
type: result.type,
|
||||
room: getRoom(room, result, context),
|
||||
resourcePath: path,
|
||||
entity: resource,
|
||||
result,
|
||||
} as RealtimeEntityEvent<T>);
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getRealtimeRecipients, getRoom, RealtimeRecipients } from "..";
|
||||
import { UpdateResult } from "../../api/crud-service";
|
||||
import { RealtimeEntityEvent, RealtimeEntityActionType } from "../../../services/realtime/types";
|
||||
import { websocketEventBus } from "../../../services/realtime/bus";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param path the path to push the notification to
|
||||
* @param resourcePath the path of the resource itself
|
||||
*/
|
||||
export function RealtimeUpdated<T>(recipients: RealtimeRecipients<T>): MethodDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Object, propertyKey: string, descriptor: PropertyDescriptor): void {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
const result: UpdateResult<T> = await originalMethod.apply(this, args);
|
||||
const context = args && args[args.length - 1];
|
||||
|
||||
if (!(result instanceof UpdateResult)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
getRealtimeRecipients(recipients, result.entity, context).forEach(
|
||||
({ room, path, resource }) => {
|
||||
websocketEventBus.publish<T>(RealtimeEntityActionType.Updated, {
|
||||
type: result.type,
|
||||
room: getRoom(room, result, context),
|
||||
resourcePath: path,
|
||||
entity: resource,
|
||||
result,
|
||||
} as RealtimeEntityEvent<T>);
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SERVICENAME_METADATA } from "../api";
|
||||
|
||||
export function ServiceName(name: string): ClassDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Function): void {
|
||||
if (name) {
|
||||
Reflect.defineMetadata(SERVICENAME_METADATA, name, target.prototype);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getLogger } from "../logger";
|
||||
|
||||
const logger = getLogger("core.platform.framework.decorators.Skip");
|
||||
|
||||
type SkipCondition = () => Promise<boolean> | boolean;
|
||||
|
||||
/**
|
||||
* Skip a method call based on some condition
|
||||
*/
|
||||
export function Skip(condition: SkipCondition): MethodDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Object, _propertyKey: string, descriptor: PropertyDescriptor): void {
|
||||
logger.trace("Skipping method call");
|
||||
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
const skipIt = await (condition && condition());
|
||||
|
||||
if (skipIt) {
|
||||
return target;
|
||||
}
|
||||
|
||||
return await originalMethod.apply(this, args);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip when process.env.NODE_ENV is set to "cli"
|
||||
*/
|
||||
export function SkipCLI(): MethodDecorator {
|
||||
return Skip(() => process.env.NODE_ENV === "cli");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PREFIX_METADATA } from "../api";
|
||||
|
||||
export function WebService(prefix: string = "/"): ClassDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Function): void {
|
||||
Reflect.defineMetadata(PREFIX_METADATA, prefix, target.prototype);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Subject } from "rxjs";
|
||||
import { logger as rootLogger } from "./logger";
|
||||
import { ExecutionContext } from "./api/crud-service";
|
||||
|
||||
const logger = rootLogger.child({
|
||||
component: "tdrive.core.platform.framework.event-bus",
|
||||
});
|
||||
|
||||
/**
|
||||
* A local event bus in the platform. Used by platform components and services to communicate using publish subscribe.
|
||||
*/
|
||||
class EventBus {
|
||||
private subjects: Map<string, Subject<any>>;
|
||||
constructor() {
|
||||
this.subjects = new Map<string, Subject<any>>();
|
||||
}
|
||||
|
||||
subscribe<T>(name: string, listener: (_data: T) => void): this {
|
||||
if (!this.subjects.has(name)) {
|
||||
this.subjects.set(name, new Subject<T>());
|
||||
}
|
||||
|
||||
this.subjects.get(name).subscribe({
|
||||
next: (value: T) => {
|
||||
try {
|
||||
listener(value);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Error while calling listener");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
publish<T>(name: string, data: T, _context?: ExecutionContext): boolean {
|
||||
if (!this.subjects.has(name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.subjects.get(name)?.next(data);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const localEventBus = new EventBus();
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
TdriveContext,
|
||||
TdriveService,
|
||||
TdriveServiceConfiguration,
|
||||
TdriveServiceOptions,
|
||||
TdriveServiceProvider,
|
||||
} from "./api";
|
||||
import { Configuration } from "./configuration";
|
||||
|
||||
class StaticTdriveServiceFactory {
|
||||
public async create<
|
||||
T extends TdriveService<TdriveServiceProvider> = TdriveService<TdriveServiceProvider>,
|
||||
>(
|
||||
module: { new (options?: TdriveServiceOptions<TdriveServiceConfiguration>): T },
|
||||
context: TdriveContext,
|
||||
configuration?: string,
|
||||
): Promise<T> {
|
||||
let config;
|
||||
|
||||
if (configuration) {
|
||||
config = new Configuration(configuration);
|
||||
}
|
||||
|
||||
const instance = new module({ configuration: config });
|
||||
|
||||
instance.context = context;
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
export const TdriveServiceFactory = new StaticTdriveServiceFactory();
|
||||
@@ -0,0 +1,8 @@
|
||||
import "reflect-metadata";
|
||||
|
||||
export * from "./api";
|
||||
export * from "./decorators";
|
||||
export * from "./configuration";
|
||||
export * from "./factory";
|
||||
export * from "./logger";
|
||||
export * from "./utils/component-utils";
|
||||
@@ -0,0 +1,15 @@
|
||||
import pino from "pino";
|
||||
import { Configuration } from "./configuration";
|
||||
|
||||
const config = new Configuration("logger");
|
||||
|
||||
export type TdriveLogger = pino.Logger;
|
||||
|
||||
export const logger = pino({
|
||||
name: "TdriveApp",
|
||||
level: config.get("level", "info") || "info",
|
||||
prettyPrint: false,
|
||||
});
|
||||
|
||||
export const getLogger = (name?: string): TdriveLogger =>
|
||||
logger.child({ name: `tdrive${name ? "." + name : ""}` });
|
||||
@@ -0,0 +1,108 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types */
|
||||
import {
|
||||
logger,
|
||||
TdriveComponent,
|
||||
TdriveContext,
|
||||
TdriveServiceFactory,
|
||||
TdriveServiceState,
|
||||
} from "../index";
|
||||
import { Loader } from "./loader";
|
||||
|
||||
export async function buildDependenciesTree(
|
||||
components: Map<string, TdriveComponent>,
|
||||
loadComponent: (name: string) => any,
|
||||
): Promise<void> {
|
||||
for (const [name, component] of components) {
|
||||
const dependencies: string[] = component.getServiceInstance().getConsumes() || [];
|
||||
|
||||
for (const dependencyName of dependencies) {
|
||||
if (name === dependencyName) {
|
||||
throw new Error(`There is a circular dependency for component ${dependencyName}`);
|
||||
}
|
||||
let dependencyComponent = components.get(dependencyName);
|
||||
|
||||
if (!dependencyComponent) {
|
||||
//Except in the tests, we allow this to happen with a warning
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
throw new Error(
|
||||
`The component dependency ${dependencyName} has not been found for component ${name}`,
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`(warning) The component dependency ${dependencyName} has not been found for component ${name} it will be imported asynchronously`,
|
||||
);
|
||||
|
||||
try {
|
||||
dependencyComponent = await loadComponent(name);
|
||||
if (dependencyComponent) components.set(name, dependencyComponent);
|
||||
} catch (err) {
|
||||
dependencyComponent = null;
|
||||
}
|
||||
|
||||
if (!dependencyComponent) {
|
||||
throw new Error(
|
||||
`The component dependency ${dependencyName} has not been found for component ${name} even with async load`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component.addDependency(dependencyComponent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load specified components from given list of paths
|
||||
*
|
||||
* @param paths Paths to search components in
|
||||
* @param names Components to load
|
||||
* @param context
|
||||
*/
|
||||
export async function loadComponents(
|
||||
paths: string[],
|
||||
names: string[] = [],
|
||||
context: TdriveContext,
|
||||
): Promise<Map<string, TdriveComponent>> {
|
||||
const result = new Map<string, TdriveComponent>();
|
||||
const loader = new Loader(paths);
|
||||
|
||||
const components: TdriveComponent[] = await Promise.all(
|
||||
names.map(async name => {
|
||||
const clazz = await loader.load(name);
|
||||
const component = new TdriveComponent(name, { clazz, name });
|
||||
result.set(name, component);
|
||||
|
||||
return component;
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
components.map(async component => {
|
||||
const service = await TdriveServiceFactory.create(
|
||||
component.getServiceDefinition().clazz,
|
||||
context,
|
||||
component.getServiceDefinition().name,
|
||||
);
|
||||
|
||||
component.setServiceInstance(service);
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function switchComponentsToState(
|
||||
components: Map<string, TdriveComponent>,
|
||||
state: TdriveServiceState.Initialized | TdriveServiceState.Started | TdriveServiceState.Stopped,
|
||||
): Promise<void> {
|
||||
const states = [];
|
||||
|
||||
for (const [name, component] of components) {
|
||||
logger.info(`Asking for ${state} on ${name} dependencies`);
|
||||
states.push(component.getServiceInstance().state);
|
||||
await component.switchToState(state);
|
||||
}
|
||||
|
||||
logger.info(`All components are now in ${state} state`);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { logger } from "../logger";
|
||||
import fs from "fs";
|
||||
|
||||
export class Loader {
|
||||
constructor(readonly paths: string[]) {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async load(componentName: string): Promise<any> {
|
||||
const modulesPaths = this.paths.map(path => `${path}/${componentName}`);
|
||||
|
||||
let classes = await Promise.all(
|
||||
modulesPaths.map(async modulePath => {
|
||||
if (fs.existsSync(modulePath)) {
|
||||
try {
|
||||
return await import(modulePath);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
{ err },
|
||||
`${modulePath} can not be loaded (file was found but we were unable to import the module)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
classes = classes.filter(Boolean);
|
||||
|
||||
if (!classes || !classes.length) {
|
||||
modulesPaths.map(modulePath => {
|
||||
if (fs.existsSync(modulePath)) {
|
||||
logger.debug(`${modulePath} content was: [${fs.readdirSync(modulePath).join(", ")}]`);
|
||||
}
|
||||
});
|
||||
throw new Error(
|
||||
`Can not find or load ${componentName} in any given path [${modulesPaths.join(
|
||||
" - ",
|
||||
)}] see previous logs for more details.`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(`Loaded ${componentName}`);
|
||||
|
||||
return classes[0].default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { TdriveContainer, TdriveServiceProvider, TdriveComponent } from "./framework";
|
||||
import * as ComponentUtils from "./framework/utils/component-utils";
|
||||
import path from "path";
|
||||
|
||||
export class TdrivePlatform extends TdriveContainer {
|
||||
constructor(protected options: TdrivePlatformConfiguration) {
|
||||
super();
|
||||
}
|
||||
|
||||
api(): TdriveServiceProvider {
|
||||
return null;
|
||||
}
|
||||
|
||||
async loadComponents(): Promise<Map<string, TdriveComponent>> {
|
||||
return await ComponentUtils.loadComponents(
|
||||
[this.options.servicesPath, path.resolve(__dirname, "./services/")],
|
||||
this.options.services,
|
||||
{
|
||||
getProvider: this.getProvider.bind(this),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async loadComponent(name: string): Promise<TdriveComponent> {
|
||||
return (
|
||||
await ComponentUtils.loadComponents(
|
||||
[this.options.servicesPath, path.resolve(__dirname, "./services/")],
|
||||
[name],
|
||||
{
|
||||
getProvider: this.getProvider.bind(this),
|
||||
},
|
||||
)
|
||||
).get(name);
|
||||
}
|
||||
}
|
||||
|
||||
export class TdrivePlatformConfiguration {
|
||||
/**
|
||||
* The services to load in the container
|
||||
*/
|
||||
services: string[];
|
||||
|
||||
/**
|
||||
* The path to load services from
|
||||
*/
|
||||
servicesPath: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { TdriveService, Consumes, Prefix, ServiceName } from "../../framework";
|
||||
import web from "./web/index";
|
||||
import AuthServiceAPI, { JwtConfiguration } from "./provider";
|
||||
import { AuthService as AuthServiceImpl } from "./service";
|
||||
import WebServerAPI from "../webserver/provider";
|
||||
|
||||
@Prefix("/api/auth")
|
||||
@Consumes(["webserver"])
|
||||
@ServiceName("auth")
|
||||
export default class AuthService extends TdriveService<AuthServiceAPI> {
|
||||
name = "auth";
|
||||
service: AuthServiceAPI;
|
||||
|
||||
api(): AuthServiceAPI {
|
||||
return this.service;
|
||||
}
|
||||
|
||||
public async doInit(): Promise<this> {
|
||||
this.service = new AuthServiceImpl(this.configuration.get<JwtConfiguration>("jwt"));
|
||||
const fastify = this.context.getProvider<WebServerAPI>("webserver").getServer();
|
||||
|
||||
fastify.register((instance, _opts, next) => {
|
||||
web(instance, { prefix: this.prefix });
|
||||
next();
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { TdriveServiceProvider } from "../../framework/api";
|
||||
import { AccessToken, JWTObject, uuid } from "../../../../utils/types";
|
||||
|
||||
export default interface AuthServiceAPI extends TdriveServiceProvider {
|
||||
/**
|
||||
* Get the authentication types
|
||||
*/
|
||||
getTypes(): Array<string>;
|
||||
|
||||
/**
|
||||
* Sign payload
|
||||
*/
|
||||
sign(payload: any): string;
|
||||
|
||||
/**
|
||||
* Verify token
|
||||
*
|
||||
* @param token
|
||||
*/
|
||||
verifyToken(token: string): JWTObject;
|
||||
|
||||
verifyTokenObject<T>(token: string): T;
|
||||
|
||||
generateJWT(
|
||||
userId: uuid,
|
||||
email: string,
|
||||
options: {
|
||||
track: boolean;
|
||||
provider_id: string;
|
||||
application_id?: string;
|
||||
} & any,
|
||||
): AccessToken;
|
||||
}
|
||||
|
||||
export interface JwtConfiguration {
|
||||
secret: string;
|
||||
expiration: number;
|
||||
refresh_expiration: number;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { JwtConfiguration } from "./provider";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { AccessToken, JWTObject, uuid } from "../../../../utils/types";
|
||||
import assert from "assert";
|
||||
import { JwtType } from "../types";
|
||||
import AuthServiceAPI from "./provider";
|
||||
|
||||
export class AuthService implements AuthServiceAPI {
|
||||
version: "1";
|
||||
|
||||
constructor(private configuration: JwtConfiguration) {}
|
||||
|
||||
getTypes(): string[] {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
sign(payload: string | object | Buffer): string {
|
||||
return jwt.sign(payload, this.configuration.secret);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
verifyToken(token: string): JWTObject {
|
||||
return jwt.verify(token, this.configuration.secret) as JWTObject;
|
||||
}
|
||||
|
||||
verifyTokenObject<T>(token: string): T {
|
||||
return jwt.verify(token, this.configuration.secret) as T;
|
||||
}
|
||||
|
||||
generateJWT(
|
||||
userId: uuid,
|
||||
email: string,
|
||||
options: { track: boolean; provider_id: string; application_id?: string },
|
||||
): AccessToken {
|
||||
const now = Math.round(new Date().getTime() / 1000); // Current time in UTC
|
||||
assert(this.configuration.expiration, "jwt.expiration is missing");
|
||||
assert(this.configuration.refresh_expiration, "jwt.refresh_expiration is missing");
|
||||
|
||||
const jwtExpiration = now + this.configuration.expiration;
|
||||
const jwtRefreshExpiration = now + this.configuration.refresh_expiration;
|
||||
return {
|
||||
time: now,
|
||||
expiration: jwtExpiration,
|
||||
refresh_expiration: jwtRefreshExpiration,
|
||||
value: this.sign({
|
||||
exp: jwtExpiration,
|
||||
type: "access",
|
||||
iat: now - 60 * 10,
|
||||
nbf: now - 60 * 10,
|
||||
sub: userId,
|
||||
email: email,
|
||||
track: !!options.track,
|
||||
provider_id: options.provider_id || "",
|
||||
application_id: options.application_id,
|
||||
} as JwtType),
|
||||
refresh: this.sign({
|
||||
exp: jwtRefreshExpiration,
|
||||
type: "refresh",
|
||||
iat: now - 60 * 10,
|
||||
nbf: now - 60 * 10,
|
||||
sub: userId,
|
||||
email: email,
|
||||
track: !!options.track,
|
||||
provider_id: options.provider_id || "",
|
||||
application_id: options.application_id,
|
||||
} as JwtType),
|
||||
type: "Bearer",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { FastifyInstance, FastifyRegisterOptions } from "fastify";
|
||||
import routes from "./routes";
|
||||
|
||||
export default (
|
||||
fastify: FastifyInstance,
|
||||
opts: FastifyRegisterOptions<{ prefix: string }>,
|
||||
): void => {
|
||||
fastify.log.debug("Configuring /auth");
|
||||
fastify.register(routes, opts);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { FastifyPluginCallback, FastifyRequest } from "fastify";
|
||||
import fastifyJwt from "fastify-jwt";
|
||||
import fp from "fastify-plugin";
|
||||
import config from "../../../../config";
|
||||
import { JwtType } from "../../types";
|
||||
|
||||
const jwtPlugin: FastifyPluginCallback = (fastify, _opts, next) => {
|
||||
fastify.register(fastifyJwt, {
|
||||
secret: config.get("auth.jwt.secret"),
|
||||
});
|
||||
|
||||
const authenticate = async (request: FastifyRequest) => {
|
||||
const jwt: JwtType = await request.jwtVerify();
|
||||
if (jwt.type === "refresh") {
|
||||
// TODO in the future we must invalidate the refresh token (because it should be single use)
|
||||
}
|
||||
request.currentUser = {
|
||||
...{ email: jwt.email },
|
||||
...{ id: jwt.sub },
|
||||
...{ identity_provider_id: jwt.provider_id },
|
||||
...{ application_id: jwt.application_id || null },
|
||||
...{ server_request: jwt.server_request || false },
|
||||
...{ allow_tracking: jwt.track || false },
|
||||
};
|
||||
request.log.debug(`Authenticated as user ${request.currentUser.id}`);
|
||||
};
|
||||
|
||||
fastify.decorate("authenticate", async (request: FastifyRequest) => {
|
||||
try {
|
||||
await authenticate(request);
|
||||
} catch (err) {
|
||||
throw fastify.httpErrors.unauthorized("Bad credentials");
|
||||
}
|
||||
});
|
||||
|
||||
fastify.decorate("authenticateOptional", async (request: FastifyRequest) => {
|
||||
try {
|
||||
await authenticate(request);
|
||||
} catch (err) {}
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export default fp(jwtPlugin, {
|
||||
name: "authenticate",
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FastifyInstance, FastifyPluginCallback } from "fastify";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const routes: FastifyPluginCallback = (fastify: FastifyInstance, _opts, next) => {
|
||||
fastify.get("/login", async (_request, reply) => {
|
||||
const userId = uuidv4();
|
||||
reply.send({
|
||||
token: fastify.jwt.sign({ sub: userId }),
|
||||
user: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export default routes;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { TdriveService } from "../../framework";
|
||||
import { CounterProvider } from "./provider";
|
||||
import { CounterAPI } from "./types";
|
||||
import Repository from "../database/services/orm/repository/repository";
|
||||
|
||||
export default class CounterService extends TdriveService<CounterAPI> implements CounterAPI {
|
||||
name = "counter";
|
||||
version = "1";
|
||||
|
||||
api(): CounterAPI {
|
||||
return this;
|
||||
}
|
||||
|
||||
async doInit(): Promise<this> {
|
||||
return Promise.resolve(this);
|
||||
}
|
||||
|
||||
getCounter<T>(repository: Repository<T>): CounterProvider<T> {
|
||||
return new CounterProvider(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Repository from "../database/services/orm/repository/repository";
|
||||
import { logger } from "../../framework";
|
||||
import { ExecutionContext } from "../../framework/api/crud-service";
|
||||
|
||||
type LastRevised = {
|
||||
calls: number;
|
||||
period: number;
|
||||
};
|
||||
|
||||
export class CounterProvider<T> {
|
||||
private name = "CounterProvider";
|
||||
|
||||
protected readonly repository: Repository<T>;
|
||||
|
||||
private reviseHandler: (pk: Partial<T>) => Promise<number>;
|
||||
private reviseMaxCalls = 0;
|
||||
private reviseMaxPeriod = 0;
|
||||
|
||||
private lastRevisedMap = new Map<string, LastRevised>();
|
||||
|
||||
constructor(repository: Repository<T>) {
|
||||
this.repository = repository;
|
||||
logger.debug(`${this.name} Created counter provider for ${this.repository.table}`);
|
||||
}
|
||||
|
||||
async increase(pk: Partial<T>, value: number, context?: ExecutionContext): Promise<void> {
|
||||
return this.repository.save(this.repository.createEntityFromObject({ value, ...pk }), context);
|
||||
}
|
||||
|
||||
async get(pk: Partial<T>, context?: ExecutionContext): Promise<number> {
|
||||
try {
|
||||
const counter = await this.repository.findOne(pk, {}, context);
|
||||
const val = counter ? (counter as any).value : 0;
|
||||
return this.revise(pk, val);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
setReviseCallback(
|
||||
handler: (pk: Partial<T>) => Promise<number>,
|
||||
maxCalls: number = 10,
|
||||
maxPeriod: number = 24 * 60 * 60 * 1000,
|
||||
): void {
|
||||
logger.debug(`${this.name} Set setReviseCallback for ${this.repository.table}`);
|
||||
this.reviseHandler = handler;
|
||||
this.reviseMaxCalls = maxCalls;
|
||||
this.reviseMaxPeriod = maxPeriod;
|
||||
}
|
||||
|
||||
private async revise(pk: Partial<T>, currentValue: number): Promise<number> {
|
||||
const now = new Date().getTime();
|
||||
const lastRevised: LastRevised = this.lastRevisedMap.get(JSON.stringify(pk)) || {
|
||||
calls: -1,
|
||||
period: now,
|
||||
};
|
||||
|
||||
logger.debug(
|
||||
`${this.name} revision status for ${JSON.stringify(pk)} is ${JSON.stringify(lastRevised)}`,
|
||||
);
|
||||
|
||||
if (
|
||||
lastRevised.calls >= this.reviseMaxCalls ||
|
||||
now > lastRevised.period + this.reviseMaxPeriod ||
|
||||
Math.random() < 1 / Math.max(1, currentValue) //The slowest the number is, the more we update it
|
||||
) {
|
||||
if (!this.reviseHandler) {
|
||||
logger.debug(
|
||||
`${this.name} No setReviseCallback handler found for ${this.repository.table}`,
|
||||
);
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
logger.debug(`${this.name} Execute setReviseCallback handler for ${this.repository.table}`);
|
||||
|
||||
const actual = await this.reviseHandler(pk);
|
||||
|
||||
if (actual !== undefined && actual != currentValue) {
|
||||
await this.increase(pk, actual - currentValue);
|
||||
currentValue = actual;
|
||||
}
|
||||
lastRevised.calls = 0;
|
||||
lastRevised.period = now;
|
||||
} else {
|
||||
lastRevised.calls++;
|
||||
}
|
||||
|
||||
this.lastRevisedMap.set(JSON.stringify(pk), lastRevised);
|
||||
return currentValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { TdriveServiceProvider } from "../../framework";
|
||||
import Repository from "../database/services/orm/repository/repository";
|
||||
import { CounterProvider } from "./provider";
|
||||
|
||||
export interface CounterAPI extends TdriveServiceProvider {
|
||||
getCounter<T>(repository: Repository<T>): CounterProvider<T>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ScheduledTask } from "node-cron";
|
||||
import { TdriveServiceProvider } from "../../framework";
|
||||
|
||||
export type CronJob = () => void;
|
||||
|
||||
export type CronExpression = string;
|
||||
|
||||
export type CronTask = {
|
||||
id: string;
|
||||
description: string;
|
||||
task: ScheduledTask;
|
||||
startDate: number;
|
||||
lastRun: number;
|
||||
nbRuns: number;
|
||||
nbErrors: number;
|
||||
lastError?: Error;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export interface CronAPI extends TdriveServiceProvider {
|
||||
/**
|
||||
* Schedule a Job
|
||||
*
|
||||
* @param expression
|
||||
* @param job
|
||||
* @param callback
|
||||
*/
|
||||
schedule(expression: CronExpression, job: CronJob, description?: string): CronTask;
|
||||
|
||||
/**
|
||||
* Get the list of current tasks
|
||||
*/
|
||||
getTasks(): CronTask[];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as cron from "node-cron";
|
||||
import uuid from "node-uuid";
|
||||
|
||||
import { TdriveService } from "../../framework";
|
||||
import { CronAPI, CronJob, CronExpression, CronTask } from "./api";
|
||||
|
||||
export default class CronService extends TdriveService<CronAPI> implements CronAPI {
|
||||
name = "cron";
|
||||
version = "1";
|
||||
private tasks = new Array<CronTask>();
|
||||
|
||||
api(): CronAPI {
|
||||
return this;
|
||||
}
|
||||
|
||||
schedule(expression: CronExpression, job: CronJob, description?: string): CronTask {
|
||||
this.logger.debug(`Submit new job with name ${description}`);
|
||||
const task: CronTask = {
|
||||
id: uuid.v4(),
|
||||
description: description || "",
|
||||
startDate: Date.now(),
|
||||
lastRun: 0,
|
||||
nbErrors: 0,
|
||||
nbRuns: 0,
|
||||
task: cron.schedule(expression, () => {
|
||||
task.lastRun = Date.now();
|
||||
task.nbRuns++;
|
||||
this.logger.debug(`Running job ${description || "untitled"}`);
|
||||
try {
|
||||
job();
|
||||
} catch (err) {
|
||||
this.logger.error("Error while running job", err);
|
||||
task.nbErrors++;
|
||||
task.lastError = err;
|
||||
}
|
||||
}),
|
||||
stop: () => {
|
||||
task.task.stop();
|
||||
},
|
||||
start: () => {
|
||||
task.task.start();
|
||||
},
|
||||
};
|
||||
|
||||
this.tasks.push(task);
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
getTasks(): CronTask[] {
|
||||
return this.tasks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TdriveServiceProvider } from "../../framework";
|
||||
import { Connector } from "./services/orm/connectors";
|
||||
import Manager from "./services/orm/manager";
|
||||
import Repository from "./services/orm/repository/repository";
|
||||
import { EntityTarget } from "./services/orm/types";
|
||||
|
||||
export interface DatabaseServiceAPI extends TdriveServiceProvider {
|
||||
/**
|
||||
* Get the database connector
|
||||
*/
|
||||
getConnector(): Connector;
|
||||
|
||||
/**
|
||||
* Get entities manager (TODO: Find a better name...)
|
||||
*/
|
||||
getManager(): Manager<unknown>;
|
||||
|
||||
/**
|
||||
* Get repository for given entity
|
||||
*
|
||||
* @param table
|
||||
* @param entity
|
||||
*/
|
||||
getRepository<Entity>(table: string, entity: EntityTarget<Entity>): Promise<Repository<Entity>>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { TdriveService, logger, ServiceName } from "../../framework";
|
||||
import { DatabaseServiceAPI } from "./api";
|
||||
import DatabaseService from "./services";
|
||||
import { DatabaseType } from "./services";
|
||||
import { ConnectionOptions } from "./services/orm/connectors";
|
||||
|
||||
@ServiceName("database")
|
||||
export default class Database extends TdriveService<DatabaseServiceAPI> {
|
||||
version = "1";
|
||||
name = "database";
|
||||
service: DatabaseService;
|
||||
|
||||
public async doInit(): Promise<this> {
|
||||
const driver = this.configuration.get<DatabaseType>("type");
|
||||
const secret = this.configuration.get<string>("secret");
|
||||
|
||||
if (!driver) {
|
||||
throw new Error("Database driver name must be specified in 'database.type' configuration");
|
||||
}
|
||||
|
||||
const configuration: ConnectionOptions = this.configuration.get<ConnectionOptions>(driver);
|
||||
|
||||
this.service = new DatabaseService(driver, configuration, secret);
|
||||
const dbConnector = this.service.getConnector();
|
||||
|
||||
try {
|
||||
logger.info("Connecting to database %o", configuration);
|
||||
await dbConnector.connect();
|
||||
await dbConnector.init();
|
||||
logger.info("Connected to database");
|
||||
} catch (err) {
|
||||
logger.error("Failed to connect to database", err);
|
||||
throw new Error("Failed to connect to db");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
api(): DatabaseServiceAPI {
|
||||
return this.service;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { DatabaseType } from ".";
|
||||
import { ConnectionOptions, Connector } from "./orm/connectors";
|
||||
import {
|
||||
CassandraConnectionOptions,
|
||||
CassandraConnector,
|
||||
} from "./orm/connectors/cassandra/cassandra";
|
||||
import { MongoConnectionOptions, MongoConnector } from "./orm/connectors/mongodb/mongodb";
|
||||
|
||||
export class ConnectorFactory {
|
||||
public create(type: DatabaseType, options: ConnectionOptions, secret: string): Connector {
|
||||
switch (type) {
|
||||
case "cassandra":
|
||||
return new CassandraConnector(type, options as CassandraConnectionOptions, secret);
|
||||
case "mongodb":
|
||||
return new MongoConnector(type, options as MongoConnectionOptions, secret);
|
||||
default:
|
||||
throw new Error(`${type} is not supported`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { DatabaseServiceAPI } from "../api";
|
||||
import { ConnectorFactory } from "./connector-factory";
|
||||
import { Connector } from "./orm/connectors";
|
||||
import Manager from "./orm/manager";
|
||||
import Repository from "./orm/repository/repository";
|
||||
import { CassandraConnectionOptions } from "./orm/connectors/cassandra/cassandra";
|
||||
import { MongoConnectionOptions } from "./orm/connectors/mongodb/mongodb";
|
||||
import { EntityTarget } from "./orm/types";
|
||||
import { RepositoryManager } from "./orm/repository/manager";
|
||||
|
||||
export default class DatabaseService implements DatabaseServiceAPI {
|
||||
version = "1";
|
||||
private connector: Connector;
|
||||
private entityManager: RepositoryManager;
|
||||
|
||||
constructor(
|
||||
readonly type: DatabaseType,
|
||||
private options: ConnectionOptions,
|
||||
readonly secret: string,
|
||||
) {
|
||||
this.entityManager = new RepositoryManager(this);
|
||||
}
|
||||
|
||||
getConnector(): Connector {
|
||||
if (this.connector) {
|
||||
return this.connector;
|
||||
}
|
||||
|
||||
this.connector = new ConnectorFactory().create(this.type, this.options, this.secret);
|
||||
|
||||
return this.connector;
|
||||
}
|
||||
|
||||
getManager(): Manager<unknown> {
|
||||
return new Manager<unknown>(this.connector);
|
||||
}
|
||||
|
||||
getRepository<Entity>(table: string, entity: EntityTarget<Entity>): Promise<Repository<Entity>> {
|
||||
return this.entityManager.getRepository(table, entity);
|
||||
}
|
||||
}
|
||||
|
||||
export declare type ConnectionOptions = MongoConnectionOptions | CassandraConnectionOptions;
|
||||
|
||||
export declare type DatabaseType = "mongodb" | "cassandra";
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Connector } from ".";
|
||||
import { ConnectionOptions, DatabaseType } from "../..";
|
||||
import { FindOptions } from "../repository/repository";
|
||||
import { ColumnDefinition, EntityDefinition } from "../types";
|
||||
import { ListResult } from "../../../../../framework/api/crud-service";
|
||||
|
||||
export abstract class AbstractConnector<T extends ConnectionOptions, DatabaseClient>
|
||||
implements Connector
|
||||
{
|
||||
constructor(protected type: DatabaseType, protected options: T, protected secret: string) {}
|
||||
|
||||
abstract connect(): Promise<this>;
|
||||
|
||||
abstract drop(): Promise<this>;
|
||||
|
||||
abstract getClient(): DatabaseClient;
|
||||
|
||||
abstract createTable(
|
||||
entity: EntityDefinition,
|
||||
columns: { [name: string]: ColumnDefinition },
|
||||
): Promise<boolean>;
|
||||
|
||||
abstract upsert(entities: any[]): Promise<boolean[]>;
|
||||
|
||||
abstract remove(entities: any[]): Promise<boolean[]>;
|
||||
|
||||
abstract find<EntityType>(
|
||||
entityType: any,
|
||||
filters: any,
|
||||
options: FindOptions,
|
||||
): Promise<ListResult<EntityType>>;
|
||||
|
||||
getOptions(): T {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
getType(): DatabaseType {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
getSecret(): string {
|
||||
return this.secret;
|
||||
}
|
||||
}
|
||||
+578
@@ -0,0 +1,578 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import cassandra, { types } from "cassandra-driver";
|
||||
import { md5 } from "../../../../../../../crypto";
|
||||
import { defer, Subject, throwError, timer } from "rxjs";
|
||||
import { concat, delayWhen, retryWhen, take, tap } from "rxjs/operators";
|
||||
import { UpsertOptions } from "..";
|
||||
import { logger } from "../../../../../../framework";
|
||||
import { getEntityDefinition, unwrapIndexes, unwrapPrimarykey } from "../../utils";
|
||||
import { EntityDefinition, ColumnDefinition, ObjectType } from "../../types";
|
||||
import { AbstractConnector } from "../abstract-connector";
|
||||
import {
|
||||
transformValueToDbString,
|
||||
cassandraType,
|
||||
transformValueFromDbString,
|
||||
} from "./typeTransforms";
|
||||
import { FindOptions } from "../../repository/repository";
|
||||
import { ListResult, Pagination } from "../../../../../../framework/api/crud-service";
|
||||
import { Paginable } from "../../../../../../framework/api/crud-service";
|
||||
import { buildSelectQuery } from "./query-builder";
|
||||
|
||||
export { CassandraPagination } from "./pagination";
|
||||
|
||||
export interface CassandraConnectionOptions {
|
||||
contactPoints: string[];
|
||||
localDataCenter: string;
|
||||
username: string;
|
||||
password: string;
|
||||
keyspace: string;
|
||||
|
||||
/**
|
||||
* Consistency level
|
||||
*/
|
||||
queryOptions: { consistency: number };
|
||||
|
||||
/**
|
||||
* Wait for keyspace and tables to be created at init
|
||||
*/
|
||||
wait: boolean;
|
||||
|
||||
/**
|
||||
* When wait = true, retry to get the resources N times where N = retries
|
||||
*/
|
||||
retries?: number;
|
||||
|
||||
/**
|
||||
* Delay in ms between the retries. The delay is growing each time a retry fails like delay = retryCount * delay
|
||||
*/
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export class CassandraConnector extends AbstractConnector<
|
||||
CassandraConnectionOptions,
|
||||
cassandra.Client
|
||||
> {
|
||||
private client: cassandra.Client;
|
||||
private keyspaceExists = false;
|
||||
|
||||
getClient(): cassandra.Client {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async init(): Promise<this> {
|
||||
if (!this.client) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
try {
|
||||
await this.createKeyspace();
|
||||
} catch (err) {
|
||||
logger.warn("services.database.orm.cassandra - Keyspace can not be created", err);
|
||||
}
|
||||
|
||||
if (this.options.wait) {
|
||||
await this.waitForKeyspace(this.options.delay, this.options.retries);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
createKeyspace(): Promise<cassandra.types.ResultSet> {
|
||||
const query = `CREATE KEYSPACE IF NOT EXISTS ${this.options.keyspace} WITH replication = {'class': 'NetworkTopologyStrategy', 'datacenter1': '2'} AND durable_writes = true;`;
|
||||
logger.info(query);
|
||||
return this.client.execute(query);
|
||||
}
|
||||
|
||||
async isKeyspaceCreated(): Promise<boolean> {
|
||||
let result;
|
||||
|
||||
if (this.keyspaceExists) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
result = await this.client.execute(
|
||||
`SELECT * FROM system_schema.keyspaces where keyspace_name = '${this.options.keyspace}'`,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new Error("No result for keyspace query");
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error("Keyspace query error");
|
||||
}
|
||||
|
||||
if (result) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
this.keyspaceExists = true;
|
||||
logger.info(`Keyspace '${this.options.keyspace}' found.`);
|
||||
}
|
||||
|
||||
return result ? Promise.resolve(true) : Promise.reject(new Error("Keyspace not found"));
|
||||
}
|
||||
|
||||
waitForKeyspace(delay: number = 500, retries: number = 10): Promise<boolean> {
|
||||
const subject = new Subject<boolean>();
|
||||
const obs$ = defer(() => this.isKeyspaceCreated());
|
||||
|
||||
obs$
|
||||
.pipe(
|
||||
retryWhen(errors =>
|
||||
errors.pipe(
|
||||
delayWhen((_, i) => timer(i * delay)),
|
||||
//delay(1000), if we want fixed delay
|
||||
tap(() => logger.debug("services.database.orm.cassandra - Retrying...")),
|
||||
take(retries),
|
||||
concat(throwError("Maximum number of retries reached")),
|
||||
),
|
||||
),
|
||||
)
|
||||
.subscribe(
|
||||
() => logger.debug("services.database.orm.cassandra - Keyspace has been found"),
|
||||
err => {
|
||||
logger.error(
|
||||
{ err },
|
||||
"services.database.orm.cassandra - Error while getting keyspace information",
|
||||
);
|
||||
subject.error(new Error("Can not find keyspace information"));
|
||||
},
|
||||
() => subject.complete(),
|
||||
);
|
||||
|
||||
return subject.toPromise();
|
||||
}
|
||||
|
||||
async drop(): Promise<this> {
|
||||
logger.info("Drop database is not implemented for security reasons.");
|
||||
return this;
|
||||
}
|
||||
|
||||
async connect(): Promise<this> {
|
||||
if (this.client) {
|
||||
return this;
|
||||
}
|
||||
|
||||
// Environment variable format is comma separated string
|
||||
const contactPoints =
|
||||
typeof this.options.contactPoints === "string"
|
||||
? (this.options.contactPoints as string).split(",")
|
||||
: this.options.contactPoints;
|
||||
|
||||
const cassandraOptions: cassandra.DseClientOptions = {
|
||||
contactPoints: contactPoints,
|
||||
localDataCenter: this.options.localDataCenter,
|
||||
queryOptions: {},
|
||||
};
|
||||
|
||||
if (this.options.username && this.options.password) {
|
||||
cassandraOptions.authProvider = new cassandra.auth.PlainTextAuthProvider(
|
||||
this.options.username,
|
||||
this.options.password,
|
||||
);
|
||||
}
|
||||
|
||||
//Set default consistency level to quorum
|
||||
cassandraOptions.queryOptions.consistency =
|
||||
this.options?.queryOptions?.consistency || types.consistencies.quorum;
|
||||
|
||||
this.client = new cassandra.Client(cassandraOptions);
|
||||
await this.client.connect();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async getTableDefinition(name: string): Promise<string[]> {
|
||||
let result: string[];
|
||||
|
||||
try {
|
||||
const query = `SELECT column_name from system_schema.columns WHERE keyspace_name = '${this.options.keyspace}' AND table_name='${name}';`;
|
||||
const dbResult = await this.client.execute(query);
|
||||
result = dbResult.rows.map(row => row.values()[0]);
|
||||
} catch (err) {
|
||||
throw "Table query error";
|
||||
}
|
||||
|
||||
return result ? Promise.resolve(result || []) : Promise.resolve([]);
|
||||
}
|
||||
|
||||
async createTable(
|
||||
entity: EntityDefinition,
|
||||
columns: { [name: string]: ColumnDefinition },
|
||||
): Promise<boolean> {
|
||||
await this.waitForKeyspace(this.options.delay, this.options.retries);
|
||||
|
||||
let result = true;
|
||||
|
||||
// --- Generate column and key definition --- //
|
||||
const primaryKey = entity.options.primaryKey || [];
|
||||
|
||||
if (primaryKey.length === 0) {
|
||||
logger.error(
|
||||
`services.database.orm.cassandra - Primary key was not defined for table ${entity.name}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const partitionKeyPart = primaryKey.shift();
|
||||
const partitionKey: string[] =
|
||||
typeof partitionKeyPart === "string" ? [partitionKeyPart] : partitionKeyPart;
|
||||
const clusteringKeys: string[] = primaryKey as string[];
|
||||
if ([...partitionKey, ...clusteringKeys].some(key => columns[key] === undefined)) {
|
||||
logger.error(
|
||||
`services.database.orm.cassandra - One primary key item doesn't exists in entity columns for table ${entity.name}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const clusteringOrderBy = clusteringKeys.map(key => {
|
||||
const direction: "ASC" | "DESC" = columns[key].options.order || "ASC";
|
||||
return `${key} ${direction}`;
|
||||
});
|
||||
const clusteringOrderByString =
|
||||
clusteringOrderBy.length > 0
|
||||
? `WITH CLUSTERING ORDER BY (${clusteringOrderBy.join(", ")})`
|
||||
: "";
|
||||
|
||||
const allKeys = [`(${partitionKey.join(", ")})`, ...clusteringKeys];
|
||||
const primaryKeyString = `(${allKeys.join(", ")})`;
|
||||
|
||||
const columnsString = Object.keys(columns)
|
||||
.map(colName => {
|
||||
const definition = columns[colName];
|
||||
return `${colName} ${cassandraType[definition.type]}`;
|
||||
})
|
||||
.join(",\n");
|
||||
|
||||
// --- Generate final create table query --- //
|
||||
let query = `
|
||||
CREATE TABLE IF NOT EXISTS ${this.options.keyspace}.${entity.name}
|
||||
(
|
||||
${columnsString},
|
||||
PRIMARY KEY ${primaryKeyString}
|
||||
) ${clusteringOrderByString};`;
|
||||
|
||||
// --- Alter table if not up to date --- //
|
||||
const existingColumns = await this.getTableDefinition(entity.name);
|
||||
if (existingColumns.length > 0) {
|
||||
logger.debug(
|
||||
`services.database.orm.cassandra - Existing columns for table ${entity.name}, generating altertable queries`,
|
||||
);
|
||||
const alterQueryColumns = Object.keys(columns)
|
||||
.filter(colName => existingColumns.indexOf(colName) < 0)
|
||||
.map(colName => `${colName} ${cassandraType[columns[colName].type]}`);
|
||||
query = `ALTER TABLE ${this.options.keyspace}.${entity.name} ADD (${alterQueryColumns.join(
|
||||
", ",
|
||||
)})`;
|
||||
|
||||
if (alterQueryColumns.length === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Write table --- //
|
||||
try {
|
||||
logger.debug(`service.database.orm.createTable - Creating table ${entity.name} : ${query}`);
|
||||
await this.client.execute(query);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`service.database.orm.createTable - creation error for table ${entity.name} : ${err.message}`,
|
||||
);
|
||||
result = false;
|
||||
}
|
||||
|
||||
// --- Create indexes --- //
|
||||
if (entity.options.globalIndexes) {
|
||||
for (const globalIndex of entity.options.globalIndexes) {
|
||||
const indexName = globalIndex.join("_");
|
||||
const indexDbName = `index_${md5(indexName)}`;
|
||||
|
||||
const query = `CREATE INDEX IF NOT EXISTS ${indexDbName} ON ${this.options.keyspace}."${
|
||||
entity.name
|
||||
}" (${
|
||||
globalIndex.length === 1 ? globalIndex[0] : `(${globalIndex[0]}), ${globalIndex[1]}`
|
||||
})`;
|
||||
|
||||
try {
|
||||
logger.debug(
|
||||
`service.database.orm.createTable - Creating index ${indexName} (${indexDbName}) : ${query}`,
|
||||
);
|
||||
await this.client.execute(query);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`service.database.orm.createTable - creation error for index ${indexName} (${indexDbName}) : ${err.message}`,
|
||||
);
|
||||
result = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async upsert(entities: any[], _options: UpsertOptions = {}): Promise<boolean[]> {
|
||||
return new Promise(resolve => {
|
||||
const promises: Promise<boolean>[] = [];
|
||||
|
||||
entities.forEach(entity => {
|
||||
const { columnsDefinition, entityDefinition } = getEntityDefinition(entity);
|
||||
const primaryKey = unwrapPrimarykey(entityDefinition);
|
||||
|
||||
//Set updated content
|
||||
const set = Object.keys(columnsDefinition)
|
||||
.filter(key => primaryKey.indexOf(key) === -1)
|
||||
.filter(key => entity[columnsDefinition[key].nodename] !== undefined)
|
||||
.map(key => [
|
||||
`${key}`,
|
||||
`${transformValueToDbString(
|
||||
entity[columnsDefinition[key].nodename],
|
||||
columnsDefinition[key].type,
|
||||
{
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: this.secret,
|
||||
column: { key },
|
||||
},
|
||||
)}`,
|
||||
]);
|
||||
//Set primary key
|
||||
const where = primaryKey.map(key => [
|
||||
`${key}`,
|
||||
`${transformValueToDbString(
|
||||
entity[columnsDefinition[key].nodename],
|
||||
columnsDefinition[key].type,
|
||||
{
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: this.secret,
|
||||
disableSalts: true,
|
||||
column: { key },
|
||||
},
|
||||
)}`,
|
||||
]);
|
||||
|
||||
// Add time-to-live options
|
||||
let ttlOptions = "";
|
||||
if (entityDefinition.options.ttl && entityDefinition.options.ttl > 0) {
|
||||
ttlOptions = `USING TTL ${entityDefinition.options.ttl}`;
|
||||
}
|
||||
|
||||
// Insert and update are equivalent for most of Cassandra
|
||||
// Update is prefered because the only solution for counters
|
||||
let query = `UPDATE ${this.options.keyspace}.${
|
||||
entityDefinition.name
|
||||
} ${ttlOptions} SET ${set.map(e => `${e[0]} = ${e[1]}`).join(", ")} WHERE ${where
|
||||
.map(e => `${e[0]} = ${e[1]}`)
|
||||
.join(" AND ")}`;
|
||||
|
||||
// If no "set" part, we cannot do an update, so insert
|
||||
if (set.length === 0) {
|
||||
query = `INSERT INTO ${this.options.keyspace}.${
|
||||
entityDefinition.name
|
||||
} ${ttlOptions} (${where.map(e => e[0]).join(", ")}) VALUES (${where
|
||||
.map(e => e[1])
|
||||
.join(", ")})`;
|
||||
}
|
||||
|
||||
logger.debug(`service.database.orm.upsert - Query: "${query}"`);
|
||||
|
||||
promises.push(
|
||||
new Promise(async resolve => {
|
||||
try {
|
||||
await this.getClient().execute(query);
|
||||
resolve(true);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err },
|
||||
`services.database.orm.cassandra - Error with CQL query: ${query}`,
|
||||
);
|
||||
resolve(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Promise.all(promises).then(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async remove(entities: any[]): Promise<boolean[]> {
|
||||
return new Promise(resolve => {
|
||||
const promises: Promise<boolean>[] = [];
|
||||
|
||||
entities.forEach(entity => {
|
||||
const { columnsDefinition, entityDefinition } = getEntityDefinition(entity);
|
||||
const primaryKey = unwrapPrimarykey(entityDefinition);
|
||||
|
||||
//Set primary key
|
||||
const where = primaryKey.map(
|
||||
key =>
|
||||
`${key} = ${transformValueToDbString(
|
||||
entity[columnsDefinition[key].nodename],
|
||||
columnsDefinition[key].type,
|
||||
{
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: this.secret,
|
||||
disableSalts: true,
|
||||
column: { key },
|
||||
},
|
||||
)}`,
|
||||
);
|
||||
|
||||
const query = `DELETE FROM ${this.options.keyspace}.${
|
||||
entityDefinition.name
|
||||
} WHERE ${where.join(" AND ")}`;
|
||||
logger.debug(`services.database.orm.cassandra.remove - Query: ${query}`);
|
||||
|
||||
promises.push(
|
||||
new Promise(async resolve => {
|
||||
try {
|
||||
await this.getClient().execute(query);
|
||||
resolve(true);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err },
|
||||
`services.database.orm.cassandra.remove - Error with CQL query: ${query}`,
|
||||
);
|
||||
resolve(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Promise.all(promises).then(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async find<Table>(
|
||||
entityType: Table,
|
||||
filters: any,
|
||||
options: FindOptions = {},
|
||||
): Promise<ListResult<Table>> {
|
||||
const instance = new (entityType as any)();
|
||||
const { columnsDefinition, entityDefinition } = getEntityDefinition(instance);
|
||||
|
||||
const pk = unwrapPrimarykey(entityDefinition);
|
||||
const indexes = unwrapIndexes(entityDefinition);
|
||||
|
||||
if (
|
||||
Object.keys(filters).some(key => pk.indexOf(key) < 0) &&
|
||||
Object.keys(filters).some(key => indexes.indexOf(key) < 0)
|
||||
) {
|
||||
//Filter not in primary key
|
||||
throw new Error(
|
||||
`All filter parameters must be defined in entity primary key,
|
||||
got: ${JSON.stringify(Object.keys(filters))}
|
||||
on table ${entityDefinition.name} but pk is ${JSON.stringify(pk)},
|
||||
instance was ${JSON.stringify(instance)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const query = buildSelectQuery<Table>(
|
||||
entityType as unknown as ObjectType<Table>,
|
||||
filters,
|
||||
options,
|
||||
{
|
||||
keyspace: this.options.keyspace,
|
||||
secret: this.secret,
|
||||
},
|
||||
);
|
||||
|
||||
logger.debug(`services.database.orm.cassandra.find - Query: ${query}`);
|
||||
|
||||
const results = await this.getClient().execute(query, [], {
|
||||
fetchSize: parseInt(options.pagination.limitStr) || 100,
|
||||
pageState: options.pagination.page_token || undefined,
|
||||
prepare: false,
|
||||
});
|
||||
|
||||
const entities: Table[] = [];
|
||||
results.rows.forEach(row => {
|
||||
const entity = new (entityType as any)();
|
||||
Object.keys(row).forEach(key => {
|
||||
if (columnsDefinition[key]) {
|
||||
entity[columnsDefinition[key].nodename] = transformValueFromDbString(
|
||||
row[key],
|
||||
columnsDefinition[key].type,
|
||||
{ column: { key: key, ...columnsDefinition[key].options }, secret: this.secret },
|
||||
);
|
||||
}
|
||||
});
|
||||
entities.push(entity);
|
||||
});
|
||||
|
||||
const nextPage: Paginable = new Pagination(
|
||||
results.pageState,
|
||||
options.pagination.limitStr || "100",
|
||||
);
|
||||
logger.debug(
|
||||
`services.database.orm.cassandra.find - Query Result (items=${entities.length}): ${query}`,
|
||||
);
|
||||
|
||||
return new ListResult<Table>(entityDefinition.type, entities, nextPage);
|
||||
}
|
||||
}
|
||||
|
||||
export function waitForTable(
|
||||
client: cassandra.Client,
|
||||
keyspace: string,
|
||||
table: string,
|
||||
retries: number = 10,
|
||||
delay: number = 500,
|
||||
): Promise<boolean> {
|
||||
const subject = new Subject<boolean>();
|
||||
const obs$ = defer(() => checkForTable(client, keyspace, table));
|
||||
|
||||
obs$
|
||||
.pipe(
|
||||
retryWhen(errors =>
|
||||
errors.pipe(
|
||||
delayWhen((_, i) => timer(i * delay)),
|
||||
//delay(1000),
|
||||
tap(() =>
|
||||
logger.debug("services.database.orm.cassandra - Retrying to get table metadata..."),
|
||||
),
|
||||
take(retries),
|
||||
concat(throwError("Maximum number of retries reached")),
|
||||
),
|
||||
),
|
||||
)
|
||||
.subscribe(
|
||||
() => logger.debug(`services.database.orm.cassandra - Table ${table} has been found`),
|
||||
err => {
|
||||
logger.debug({ err }, `services.database.orm.cassandra - Table ${table} error`);
|
||||
subject.error(new Error("Can not find table"));
|
||||
},
|
||||
() => subject.complete(),
|
||||
);
|
||||
|
||||
return subject.toPromise();
|
||||
}
|
||||
|
||||
async function checkForTable(
|
||||
client: cassandra.Client,
|
||||
keyspace: string,
|
||||
table: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result: cassandra.types.ResultSet = await client.execute(
|
||||
`SELECT * FROM system_schema.tables WHERE keyspace_name='${keyspace}' AND table_name='${table}'`,
|
||||
);
|
||||
|
||||
const tableMetadata = result.rows[0];
|
||||
|
||||
logger.debug(
|
||||
"services.database.orm.cassandra.checkForTable - Table metadata %o",
|
||||
tableMetadata,
|
||||
);
|
||||
|
||||
if (!tableMetadata) {
|
||||
throw new Error("Can not find table metadata");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err },
|
||||
"services.database.orm.cassandra.checkForTable - Error while getting table metadata",
|
||||
);
|
||||
throw new Error("Error while getting table metadata");
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { Paginable } from "../../../../../../framework/api/crud-service";
|
||||
import { Pagination } from "../../../../../../framework/api/crud-service";
|
||||
|
||||
export class CassandraPagination extends Pagination {
|
||||
limit = 100;
|
||||
|
||||
private constructor(readonly page_token: string, readonly limitStr = "100") {
|
||||
super(page_token, limitStr);
|
||||
this.limit = Number.parseInt(limitStr, 10);
|
||||
}
|
||||
|
||||
static from(pagination: Paginable): CassandraPagination {
|
||||
return new CassandraPagination(pagination.page_token, pagination.limitStr);
|
||||
}
|
||||
|
||||
static next(current: Pagination, pageState: string): Paginable {
|
||||
return new Pagination(pageState, current.limitStr);
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { FindOptions } from "../../repository/repository";
|
||||
import { ObjectType } from "../../types";
|
||||
import { getEntityDefinition, secureOperators } from "../../utils";
|
||||
import { transformValueToDbString } from "./typeTransforms";
|
||||
|
||||
export function buildSelectQuery<Entity>(
|
||||
entityType: ObjectType<Entity>,
|
||||
filters: Record<string, unknown>,
|
||||
findOptions: FindOptions,
|
||||
options: {
|
||||
secret?: string;
|
||||
keyspace: string;
|
||||
} = {
|
||||
secret: "",
|
||||
keyspace: "tdrive",
|
||||
},
|
||||
): string {
|
||||
const instance = new (entityType as any)();
|
||||
const { columnsDefinition, entityDefinition } = getEntityDefinition(instance);
|
||||
|
||||
const where = Object.keys(filters)
|
||||
.map(key => {
|
||||
let result: string;
|
||||
const filter = filters[key];
|
||||
|
||||
if (!filter) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(filter)) {
|
||||
if (!filter.length) {
|
||||
return;
|
||||
}
|
||||
const inClause: string[] = filter.map(
|
||||
value =>
|
||||
`${transformValueToDbString(value, columnsDefinition[key].type, {
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: options.secret || "",
|
||||
disableSalts: true,
|
||||
})}`,
|
||||
);
|
||||
|
||||
result = `${key} IN (${inClause.join(",")})`;
|
||||
} else {
|
||||
result = `${key} = ${transformValueToDbString(filter, columnsDefinition[key].type, {
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: options.secret || "",
|
||||
disableSalts: true,
|
||||
})}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
secureOperators(transformValueToDbString, findOptions, entityType, options);
|
||||
|
||||
const whereClause = `${[
|
||||
...where,
|
||||
...(buildComparison(findOptions) || []),
|
||||
...(buildIn(findOptions) || []),
|
||||
...(buildLike(findOptions) || []),
|
||||
].join(" AND ")}`.trimEnd();
|
||||
|
||||
let orderByClause = "";
|
||||
if (findOptions.pagination?.reversed) {
|
||||
orderByClause = `${entityDefinition.options.primaryKey
|
||||
.slice(1)
|
||||
.map(
|
||||
(key: string) =>
|
||||
`${key} ${(columnsDefinition[key].options.order || "ASC") === "ASC" ? "DESC" : "ASC"}`,
|
||||
)
|
||||
.join(", ")}`;
|
||||
}
|
||||
|
||||
const query = `SELECT * FROM ${options.keyspace}.${entityDefinition.name} ${
|
||||
whereClause.trim().length ? "WHERE " + whereClause : ""
|
||||
} ${orderByClause.trim().length ? "ORDER BY " + orderByClause : ""}`
|
||||
.trimEnd()
|
||||
.concat(";");
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export function buildComparison(options: FindOptions = {}): string[] {
|
||||
let lessClause;
|
||||
let lessEqualClause;
|
||||
let greaterClause;
|
||||
let greaterEqualClause;
|
||||
|
||||
if (options.$lt) {
|
||||
lessClause = options.$lt.map(element => `${element[0]} < ${element[1]}`);
|
||||
}
|
||||
|
||||
if (options.$lte) {
|
||||
lessEqualClause = options.$lte.map(element => `${element[0]} <= ${element[1]}`);
|
||||
}
|
||||
|
||||
if (options.$gt) {
|
||||
greaterClause = options.$gt.map(element => `${element[0]} > ${element[1]}`);
|
||||
}
|
||||
|
||||
if (options.$gte) {
|
||||
greaterEqualClause = options.$gte.map(element => `${element[0]} >= ${element[1]}`);
|
||||
}
|
||||
|
||||
return [
|
||||
...(lessClause || []),
|
||||
...(lessEqualClause || []),
|
||||
...(greaterClause || []),
|
||||
...(greaterEqualClause || []),
|
||||
];
|
||||
}
|
||||
|
||||
export function buildIn(options: FindOptions = {}): string[] {
|
||||
let inClauses: string[];
|
||||
if (options.$in) {
|
||||
inClauses = options.$in.map(element => `${element[0]} IN (${element[1].join(",")})`);
|
||||
}
|
||||
|
||||
return inClauses || [];
|
||||
}
|
||||
|
||||
export function buildLike(options: FindOptions = {}): string[] {
|
||||
let likeClauses: string[];
|
||||
if (options.$like) {
|
||||
likeClauses = options.$like.map(element => `${element[0]} LIKE '%${element[1]}%`);
|
||||
}
|
||||
|
||||
return likeClauses || [];
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { isBoolean, isInteger, isNull, isUndefined } from "lodash";
|
||||
import { ColumnOptions, ColumnType } from "../../types";
|
||||
import { decrypt, encrypt } from "../../../../../../../crypto";
|
||||
import { logger } from "../../../../../../../../core/platform/framework";
|
||||
|
||||
export const cassandraType = {
|
||||
encoded_string: "TEXT",
|
||||
encoded_json: "TEXT",
|
||||
string: "TEXT",
|
||||
json: "TEXT",
|
||||
number: "BIGINT",
|
||||
timeuuid: "TIMEUUID",
|
||||
uuid: "UUID",
|
||||
counter: "COUNTER",
|
||||
blob: "BLOB",
|
||||
boolean: "BOOLEAN",
|
||||
|
||||
// backward compatibility
|
||||
tdrive_boolean: "TINYINT",
|
||||
tdrive_int: "INT", //Depreciated
|
||||
tdrive_datetime: "TIMESTAMP", //Depreciated
|
||||
};
|
||||
|
||||
type TransformOptions = {
|
||||
secret?: any;
|
||||
disableSalts?: boolean;
|
||||
columns?: ColumnOptions;
|
||||
column?: any;
|
||||
};
|
||||
|
||||
export const transformValueToDbString = (
|
||||
v: any,
|
||||
type: ColumnType,
|
||||
options: TransformOptions = {},
|
||||
): string => {
|
||||
if (type === "tdrive_datetime") {
|
||||
if (isNaN(v) || isNull(v)) {
|
||||
return "null";
|
||||
}
|
||||
return `${v}`;
|
||||
}
|
||||
|
||||
if (type === "number" || type === "tdrive_int") {
|
||||
if (isNull(v)) {
|
||||
return "null";
|
||||
}
|
||||
if (isNaN(v)) {
|
||||
return "null";
|
||||
}
|
||||
return `${parseInt(v)}`;
|
||||
}
|
||||
if (type === "uuid" || type === "timeuuid") {
|
||||
if (isNull(v)) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
v = (v || "").toString().replace(/[^a-zA-Z0-9-]/g, "");
|
||||
return `${v}`;
|
||||
}
|
||||
if (type === "boolean") {
|
||||
//Security to avoid string with "false" in it
|
||||
if (!isInteger(v) && !isBoolean(v) && !isNull(v) && !isUndefined(v)) {
|
||||
throw new Error(`'${v}' is not a ${type}`);
|
||||
}
|
||||
return `${!!v}`;
|
||||
}
|
||||
if (type === "tdrive_boolean") {
|
||||
if (!isBoolean(v)) {
|
||||
throw new Error(`'${v}' is not a ${type}`);
|
||||
}
|
||||
return v ? "1" : "0";
|
||||
}
|
||||
if (type === "encoded_string" || type === "encoded_json") {
|
||||
if (type === "encoded_json") {
|
||||
try {
|
||||
v = JSON.stringify(v);
|
||||
} catch (err) {
|
||||
v = null;
|
||||
}
|
||||
}
|
||||
const encrypted = encrypt(v, options.secret, { disableSalts: options.disableSalts });
|
||||
return `'${(encrypted.data || "").toString().replace(/'/gm, "''")}'`;
|
||||
}
|
||||
if (type === "blob") {
|
||||
return "''"; //Not implemented yet
|
||||
}
|
||||
if (type === "string" || type === "json") {
|
||||
if (type === "json" && v !== null) {
|
||||
try {
|
||||
v = JSON.stringify(v);
|
||||
} catch (err) {
|
||||
v = null;
|
||||
}
|
||||
}
|
||||
return `'${(v || "").toString().replace(/'/gm, "''")}'`;
|
||||
}
|
||||
if (type === "counter") {
|
||||
if (isNaN(v)) throw new Error("Counter value should be a number");
|
||||
return `${options.column.key} + ${v}`;
|
||||
}
|
||||
return `'${(v || "").toString().replace(/'/gm, "''")}'`;
|
||||
};
|
||||
|
||||
export const transformValueFromDbString = (
|
||||
v: any,
|
||||
type: string,
|
||||
options: TransformOptions = {},
|
||||
): any => {
|
||||
logger.trace(`Transform value %o of type ${type}`, v);
|
||||
|
||||
if (type === "tdrive_datetime") {
|
||||
return new Date(`${v}`).getTime();
|
||||
}
|
||||
|
||||
if (v !== null && (type === "encoded_string" || type === "encoded_json")) {
|
||||
let decryptedValue: any;
|
||||
|
||||
if (typeof v === "string" && v.trim() === "") {
|
||||
return v;
|
||||
}
|
||||
|
||||
try {
|
||||
decryptedValue = decrypt(v, options.secret).data;
|
||||
} catch (err) {
|
||||
logger.debug(`Can not decrypt data (${err.message}) %o of type ${type}`, v);
|
||||
|
||||
decryptedValue = v;
|
||||
}
|
||||
|
||||
if (type === "encoded_json") {
|
||||
try {
|
||||
decryptedValue = JSON.parse(decryptedValue);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
{ err },
|
||||
`Can not parse JSON from decrypted data %o of type ${type}`,
|
||||
decryptedValue,
|
||||
);
|
||||
decryptedValue = null;
|
||||
}
|
||||
}
|
||||
|
||||
return decryptedValue;
|
||||
}
|
||||
|
||||
if (type === "tdrive_boolean" || type === "boolean") {
|
||||
return Boolean(v).valueOf();
|
||||
}
|
||||
|
||||
if (type === "json") {
|
||||
try {
|
||||
return JSON.parse(v);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "uuid" || type === "timeuuid") {
|
||||
return v ? String(v).valueOf() : null;
|
||||
}
|
||||
|
||||
if (type === "number") {
|
||||
return new Number(v).valueOf();
|
||||
}
|
||||
|
||||
if (type === "counter") {
|
||||
return new Number(v).valueOf();
|
||||
}
|
||||
|
||||
return v;
|
||||
};
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { Initializable } from "../../../../../framework";
|
||||
import { DatabaseType } from "../..";
|
||||
import { CassandraConnectionOptions } from "./cassandra/cassandra";
|
||||
import { MongoConnectionOptions } from "./mongodb/mongodb";
|
||||
import { ColumnDefinition, EntityDefinition } from "../types";
|
||||
import { FindOptions } from "../repository/repository";
|
||||
import { ListResult } from "../../../../../framework/api/crud-service";
|
||||
|
||||
export * from "./mongodb/mongodb";
|
||||
export * from "./cassandra/cassandra";
|
||||
|
||||
export type UpsertOptions = any;
|
||||
|
||||
export type RemoveOptions = any;
|
||||
|
||||
export interface Connector extends Initializable {
|
||||
/**
|
||||
* Connect to the database
|
||||
*/
|
||||
connect(): Promise<this>;
|
||||
|
||||
/**
|
||||
* Get the type of connector
|
||||
*/
|
||||
getType(): DatabaseType;
|
||||
|
||||
/**
|
||||
* Drop data
|
||||
*/
|
||||
drop(): Promise<this>;
|
||||
|
||||
/**
|
||||
* Create table
|
||||
*/
|
||||
createTable(
|
||||
entity: EntityDefinition,
|
||||
columns: { [name: string]: ColumnDefinition },
|
||||
): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Upsert
|
||||
* returns true if the object was created/updated, false otherwise
|
||||
*/
|
||||
upsert(entities: any[]): Promise<boolean[]>;
|
||||
|
||||
/**
|
||||
* Remove
|
||||
* returns true if the object was removed, false otherwise
|
||||
*/
|
||||
remove(entities: any[]): Promise<boolean[]>;
|
||||
|
||||
/**
|
||||
* Find items in database
|
||||
* returns the list of entities matching the filters and options.
|
||||
*/
|
||||
find<EntityType>(
|
||||
entityType: any,
|
||||
filters: any,
|
||||
options: FindOptions,
|
||||
): Promise<ListResult<EntityType>>;
|
||||
}
|
||||
|
||||
export declare type ConnectionOptions = MongoConnectionOptions | CassandraConnectionOptions;
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
import * as mongo from "mongodb";
|
||||
import { UpsertOptions } from "..";
|
||||
import { ListResult, Paginable, Pagination } from "../../../../../../framework/api/crud-service";
|
||||
import { FindOptions } from "../../repository/repository";
|
||||
import { ColumnDefinition, EntityDefinition, ObjectType } from "../../types";
|
||||
import { getEntityDefinition, unwrapIndexes, unwrapPrimarykey } from "../../utils";
|
||||
import { AbstractConnector } from "../abstract-connector";
|
||||
import { buildSelectQuery } from "./query-builder";
|
||||
import { transformValueFromDbString, transformValueToDbString } from "./typeTransforms";
|
||||
import { logger } from "../../../../../../framework";
|
||||
|
||||
export { MongoPagination } from "./pagination";
|
||||
|
||||
export interface MongoConnectionOptions {
|
||||
// TODO: More options
|
||||
uri: string;
|
||||
database: string;
|
||||
}
|
||||
|
||||
export class MongoConnector extends AbstractConnector<MongoConnectionOptions, mongo.MongoClient> {
|
||||
private client: mongo.MongoClient;
|
||||
|
||||
async init(): Promise<this> {
|
||||
if (!this.client) {
|
||||
await this.connect();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
async connect(): Promise<this> {
|
||||
if (this.client) {
|
||||
return this;
|
||||
}
|
||||
|
||||
this.client = new mongo.MongoClient(this.options.uri);
|
||||
await this.client.connect();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
getClient(): mongo.MongoClient {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async getDatabase(): Promise<mongo.Db> {
|
||||
await this.connect();
|
||||
|
||||
return this.client.db(this.options.database);
|
||||
}
|
||||
|
||||
async drop(): Promise<this> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
db.dropDatabase();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async createTable(
|
||||
_entity: EntityDefinition,
|
||||
_columns: { [name: string]: ColumnDefinition },
|
||||
): Promise<boolean> {
|
||||
const db = await this.getDatabase();
|
||||
const collection = db.collection(`${_entity.name}`);
|
||||
|
||||
//Mongo only need to create an index if ttl defined for entity
|
||||
if (_entity.options.ttl && _entity.options.ttl > 0) {
|
||||
const primaryKey = unwrapPrimarykey(_entity);
|
||||
const filter: any = {};
|
||||
primaryKey.forEach(key => {
|
||||
filter[key] = 1;
|
||||
});
|
||||
collection.createIndex(filter, { expireAfterSeconds: _entity.options.ttl });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
async upsert(entities: any[], _options: UpsertOptions = {}): Promise<boolean[]> {
|
||||
return new Promise(async resolve => {
|
||||
const promises: Promise<mongo.UpdateResult>[] = [];
|
||||
|
||||
const db = await this.getDatabase();
|
||||
|
||||
entities.forEach(entity => {
|
||||
const { columnsDefinition, entityDefinition } = getEntityDefinition(entity);
|
||||
const primaryKey = unwrapPrimarykey(entityDefinition);
|
||||
|
||||
//Set updated content
|
||||
const set: any = {};
|
||||
const inc: any = {};
|
||||
Object.keys(columnsDefinition)
|
||||
.filter(key => primaryKey.indexOf(key) === -1)
|
||||
.filter(key => columnsDefinition[key].nodename !== undefined)
|
||||
.forEach(key => {
|
||||
const value = transformValueToDbString(
|
||||
entity[columnsDefinition[key].nodename],
|
||||
columnsDefinition[key].type,
|
||||
{
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: this.secret,
|
||||
column: { key },
|
||||
},
|
||||
);
|
||||
|
||||
if (columnsDefinition[key].type === "counter") {
|
||||
inc[key] = value;
|
||||
} else {
|
||||
set[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
//Set primary key
|
||||
const where: any = {};
|
||||
primaryKey.forEach(key => {
|
||||
where[key] = transformValueToDbString(
|
||||
entity[columnsDefinition[key].nodename],
|
||||
columnsDefinition[key].type,
|
||||
{
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: this.secret,
|
||||
disableSalts: true,
|
||||
column: { key },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const collection = db.collection(`${entityDefinition.name}`);
|
||||
|
||||
const updateObject = { $set: { ...where, ...set } } as any;
|
||||
|
||||
if (Object.keys(inc).length) {
|
||||
updateObject.$inc = inc;
|
||||
}
|
||||
|
||||
promises.push(
|
||||
collection.updateOne(where, updateObject, {
|
||||
upsert: true,
|
||||
}) as Promise<mongo.UpdateResult>,
|
||||
);
|
||||
});
|
||||
|
||||
Promise.all(promises).then(results => {
|
||||
resolve(results.map(result => result.acknowledged));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async remove(entities: any[]): Promise<boolean[]> {
|
||||
return new Promise(async resolve => {
|
||||
const promises: Promise<mongo.DeleteResult>[] = [];
|
||||
const db = await this.getDatabase();
|
||||
|
||||
entities.forEach(entity => {
|
||||
const { columnsDefinition, entityDefinition } = getEntityDefinition(entity);
|
||||
const primaryKey = unwrapPrimarykey(entityDefinition);
|
||||
|
||||
//Set primary key
|
||||
const where: any = {};
|
||||
primaryKey.forEach(key => {
|
||||
where[key] = transformValueToDbString(
|
||||
entity[columnsDefinition[key].nodename],
|
||||
columnsDefinition[key].type,
|
||||
{
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: this.secret,
|
||||
disableSalts: true,
|
||||
column: { key },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const collection = db.collection(`${entityDefinition.name}`);
|
||||
promises.push(collection.deleteOne(where));
|
||||
});
|
||||
|
||||
Promise.all(promises).then(results => {
|
||||
resolve(results.map(result => result.acknowledged));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async find<Table>(
|
||||
entityType: Table,
|
||||
filters: any,
|
||||
options: FindOptions = {},
|
||||
): Promise<ListResult<Table>> {
|
||||
const instance = new (entityType as any)();
|
||||
const { columnsDefinition, entityDefinition } = getEntityDefinition(instance);
|
||||
|
||||
const pk = unwrapPrimarykey(entityDefinition);
|
||||
|
||||
const indexes = unwrapIndexes(entityDefinition);
|
||||
if (
|
||||
Object.keys(filters).some(key => pk.indexOf(key) < 0) &&
|
||||
Object.keys(filters).some(key => indexes.indexOf(key) < 0)
|
||||
) {
|
||||
//Filter not in primary key
|
||||
throw Error(
|
||||
"All filter parameters must be defined in entity primary key, got: " +
|
||||
JSON.stringify(Object.keys(filters)) +
|
||||
" on table " +
|
||||
entityDefinition.name +
|
||||
" but pk is " +
|
||||
JSON.stringify(pk),
|
||||
);
|
||||
}
|
||||
|
||||
const db = await this.getDatabase();
|
||||
const collection = db.collection(`${entityDefinition.name}`);
|
||||
|
||||
const query = buildSelectQuery<Table>(
|
||||
entityType as unknown as ObjectType<Table>,
|
||||
filters,
|
||||
options,
|
||||
);
|
||||
|
||||
const sort: any = {};
|
||||
for (const key of entityDefinition.options.primaryKey.slice(1)) {
|
||||
const defaultOrder =
|
||||
(columnsDefinition[key as string].options.order || "ASC") === "ASC" ? 1 : -1;
|
||||
sort[key as string] = (options?.pagination?.reversed ? -1 : 1) * defaultOrder;
|
||||
}
|
||||
|
||||
logger.debug(`services.database.orm.mongodb.find - Query: ${JSON.stringify(query)}`);
|
||||
|
||||
const cursor = collection
|
||||
.find(query)
|
||||
.sort(sort)
|
||||
.skip(Math.max(0, parseInt(options.pagination.page_token || "0")))
|
||||
.limit(Math.max(0, parseInt(options.pagination.limitStr || "100")));
|
||||
|
||||
const entities: Table[] = [];
|
||||
while (await cursor.hasNext()) {
|
||||
let row = await cursor.next();
|
||||
row = { ...row.set, ...row };
|
||||
const entity = new (entityType as any)();
|
||||
Object.keys(row)
|
||||
.filter(key => columnsDefinition[key] !== undefined)
|
||||
.forEach(key => {
|
||||
entity[columnsDefinition[key].nodename] = transformValueFromDbString(
|
||||
row[key],
|
||||
columnsDefinition[key].type,
|
||||
{ columns: columnsDefinition[key].options, secret: this.secret },
|
||||
);
|
||||
});
|
||||
entities.push(entity);
|
||||
}
|
||||
|
||||
const nextPageToken = options.pagination.page_token || "0";
|
||||
const limit = parseInt(options.pagination.limitStr);
|
||||
const nextToken = entities.length === limit && (parseInt(nextPageToken) + limit).toString(10);
|
||||
const nextPage: Paginable = new Pagination(nextToken, options.pagination.limitStr || "100");
|
||||
return new ListResult<Table>(entityDefinition.type, entities, nextPage);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { Paginable } from "../../../../../../framework/api/crud-service";
|
||||
import { Pagination } from "../../../../../../framework/api/crud-service";
|
||||
|
||||
export class MongoPagination extends Pagination {
|
||||
limit = 100;
|
||||
skip = 0;
|
||||
page = 1;
|
||||
|
||||
private constructor(readonly page_token = "1", readonly limitStr = "100") {
|
||||
super(page_token, limitStr);
|
||||
this.limit = Number.parseInt(limitStr, 10);
|
||||
this.page = Number.parseInt(page_token, 10);
|
||||
this.skip = (this.page - 1) * this.limit;
|
||||
}
|
||||
|
||||
static from(pagination: Paginable): MongoPagination {
|
||||
return new MongoPagination(pagination.page_token, pagination.limitStr);
|
||||
}
|
||||
|
||||
static next(current: MongoPagination, items: Array<unknown> = []): Paginable {
|
||||
const nextToken = items.length === current.limit && (current.page + 1).toString(10);
|
||||
|
||||
return new Pagination(nextToken, current.limitStr);
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { FindOptions } from "../../repository/repository";
|
||||
import { ObjectType } from "../../types";
|
||||
import { getEntityDefinition, secureOperators } from "../../utils";
|
||||
import { transformValueToDbString } from "./typeTransforms";
|
||||
|
||||
export function buildSelectQuery<Entity>(
|
||||
entityType: ObjectType<Entity>,
|
||||
filters: any,
|
||||
findOptions: FindOptions,
|
||||
options: {
|
||||
secret?: string;
|
||||
keyspace: string;
|
||||
} = {
|
||||
secret: "",
|
||||
keyspace: "tdrive",
|
||||
},
|
||||
): any {
|
||||
const instance = new (entityType as any)();
|
||||
const { columnsDefinition } = getEntityDefinition(instance);
|
||||
|
||||
let where: any = {};
|
||||
Object.keys(filters).forEach((key: string) => {
|
||||
if (Array.isArray(filters[key])) {
|
||||
if (!filters[key] || !filters[key].length) {
|
||||
return;
|
||||
}
|
||||
findOptions.$in = findOptions.$in || [];
|
||||
findOptions.$in.push([key, filters[key]]);
|
||||
} else if (columnsDefinition[key]) {
|
||||
where[key] = transformValueToDbString(filters[key], columnsDefinition[key].type, {
|
||||
columns: columnsDefinition[key].options,
|
||||
secret: options.secret,
|
||||
disableSalts: true,
|
||||
});
|
||||
} else {
|
||||
where[key] = filters[key];
|
||||
}
|
||||
});
|
||||
|
||||
findOptions = secureOperators(transformValueToDbString, findOptions, entityType, options);
|
||||
where = buildComparison(where, findOptions);
|
||||
where = buildIn(where, findOptions);
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
export function buildComparison(where: any, options: FindOptions = {}): string[] {
|
||||
Object.keys(options).forEach(operator => {
|
||||
if (operator === "$gt" || operator === "$gte" || operator === "$lt" || operator === "$lte") {
|
||||
(options[operator] || []).forEach(element => {
|
||||
if (!where[element[0]]) where[element[0]] = {};
|
||||
where[element[0]][operator] = element[1];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
export function buildIn(where: any, options: FindOptions = {}): any {
|
||||
if (options.$in) {
|
||||
options.$in.forEach(element => {
|
||||
if (!where[element[0]]) where[element[0]] = {};
|
||||
where[element[0]]["$in"] = element[1];
|
||||
});
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user