📁 Changed TDrive root folder (#16)

📁 Changed TDrive root folder
This commit is contained in:
Montassar Ghanmy
2023-04-11 10:04:29 +01:00
committed by GitHub
parent 3b3f67af07
commit e0615fa867
10548 changed files with 48 additions and 48 deletions
@@ -0,0 +1 @@
declare module "emoji-name-map";
+21
View File
@@ -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;
+14
View File
@@ -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;
+28
View File
@@ -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,
};
}
+67
View File
@@ -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,
};
}
}
+70
View File
@@ -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";
@@ -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;
}
}
@@ -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");
}
}
@@ -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);
}
}
@@ -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 || [];
}
@@ -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;
};
@@ -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;
@@ -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);
}
}
@@ -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);
}
}
@@ -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