@tymber/common 0.0.1-alpha.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +46 -0
  3. package/dist/App.d.ts +17 -0
  4. package/dist/App.js +236 -0
  5. package/dist/Component.d.ts +16 -0
  6. package/dist/Component.js +116 -0
  7. package/dist/ConfigService.d.ts +31 -0
  8. package/dist/ConfigService.js +75 -0
  9. package/dist/Context.d.ts +41 -0
  10. package/dist/Context.js +10 -0
  11. package/dist/DB.d.ts +15 -0
  12. package/dist/DB.js +7 -0
  13. package/dist/Endpoint.d.ts +21 -0
  14. package/dist/Endpoint.js +87 -0
  15. package/dist/EventEmitter.d.ts +9 -0
  16. package/dist/EventEmitter.js +21 -0
  17. package/dist/Handler.d.ts +8 -0
  18. package/dist/Handler.js +8 -0
  19. package/dist/HttpContext.d.ts +26 -0
  20. package/dist/HttpContext.js +11 -0
  21. package/dist/I18nService.d.ts +18 -0
  22. package/dist/I18nService.js +72 -0
  23. package/dist/Middleware.d.ts +6 -0
  24. package/dist/Middleware.js +4 -0
  25. package/dist/Module.d.ts +47 -0
  26. package/dist/Module.js +12 -0
  27. package/dist/PubSubService.d.ts +20 -0
  28. package/dist/PubSubService.js +60 -0
  29. package/dist/Repository.d.ts +29 -0
  30. package/dist/Repository.js +110 -0
  31. package/dist/Router.d.ts +10 -0
  32. package/dist/Router.js +53 -0
  33. package/dist/TemplateService.d.ts +22 -0
  34. package/dist/TemplateService.js +66 -0
  35. package/dist/View.d.ts +17 -0
  36. package/dist/View.js +48 -0
  37. package/dist/ViewRenderer.d.ts +16 -0
  38. package/dist/ViewRenderer.js +59 -0
  39. package/dist/contrib/accept-language-parser.d.ts +9 -0
  40. package/dist/contrib/accept-language-parser.js +73 -0
  41. package/dist/contrib/cookie.d.ts +33 -0
  42. package/dist/contrib/cookie.js +207 -0
  43. package/dist/contrib/template.d.ts +1 -0
  44. package/dist/contrib/template.js +107 -0
  45. package/dist/index.d.ts +32 -0
  46. package/dist/index.js +32 -0
  47. package/dist/utils/ajv.d.ts +2 -0
  48. package/dist/utils/ajv.js +10 -0
  49. package/dist/utils/camelToSnakeCase.d.ts +1 -0
  50. package/dist/utils/camelToSnakeCase.js +3 -0
  51. package/dist/utils/computeBaseUrl.d.ts +1 -0
  52. package/dist/utils/computeBaseUrl.js +37 -0
  53. package/dist/utils/computeContentType.d.ts +1 -0
  54. package/dist/utils/computeContentType.js +17 -0
  55. package/dist/utils/createDebug.d.ts +1 -0
  56. package/dist/utils/createDebug.js +4 -0
  57. package/dist/utils/createTestApp.d.ts +8 -0
  58. package/dist/utils/createTestApp.js +57 -0
  59. package/dist/utils/escapeValue.d.ts +1 -0
  60. package/dist/utils/escapeValue.js +3 -0
  61. package/dist/utils/fs.d.ts +6 -0
  62. package/dist/utils/fs.js +13 -0
  63. package/dist/utils/isAdmin.d.ts +2 -0
  64. package/dist/utils/isAdmin.js +4 -0
  65. package/dist/utils/isProduction.d.ts +1 -0
  66. package/dist/utils/isProduction.js +1 -0
  67. package/dist/utils/loadModules.d.ts +3 -0
  68. package/dist/utils/loadModules.js +83 -0
  69. package/dist/utils/randomId.d.ts +1 -0
  70. package/dist/utils/randomId.js +4 -0
  71. package/dist/utils/randomUUID.d.ts +1 -0
  72. package/dist/utils/randomUUID.js +4 -0
  73. package/dist/utils/runMigrations.d.ts +3 -0
  74. package/dist/utils/runMigrations.js +85 -0
  75. package/dist/utils/snakeToCamelCase.d.ts +1 -0
  76. package/dist/utils/snakeToCamelCase.js +3 -0
  77. package/dist/utils/sortBy.d.ts +1 -0
  78. package/dist/utils/sortBy.js +8 -0
  79. package/dist/utils/sql.d.ts +120 -0
  80. package/dist/utils/sql.js +433 -0
  81. package/dist/utils/toNodeHandler.d.ts +2 -0
  82. package/dist/utils/toNodeHandler.js +91 -0
  83. package/dist/utils/types.d.ts +3 -0
  84. package/dist/utils/types.js +1 -0
  85. package/dist/utils/waitFor.d.ts +1 -0
  86. package/dist/utils/waitFor.js +5 -0
  87. package/package.json +33 -2
  88. package/src/App.ts +319 -0
  89. package/src/Component.ts +166 -0
  90. package/src/ConfigService.ts +121 -0
  91. package/src/Context.ts +60 -0
  92. package/src/DB.ts +28 -0
  93. package/src/Endpoint.ts +118 -0
  94. package/src/EventEmitter.ts +32 -0
  95. package/src/Handler.ts +14 -0
  96. package/src/HttpContext.ts +35 -0
  97. package/src/I18nService.ts +96 -0
  98. package/src/Middleware.ts +10 -0
  99. package/src/Module.ts +60 -0
  100. package/src/PubSubService.ts +77 -0
  101. package/src/Repository.ts +158 -0
  102. package/src/Router.ts +77 -0
  103. package/src/TemplateService.ts +97 -0
  104. package/src/View.ts +60 -0
  105. package/src/ViewRenderer.ts +71 -0
  106. package/src/contrib/accept-language-parser.ts +94 -0
  107. package/src/contrib/cookie.ts +256 -0
  108. package/src/contrib/template.ts +134 -0
  109. package/src/index.ts +54 -0
  110. package/src/utils/ajv.ts +13 -0
  111. package/src/utils/camelToSnakeCase.ts +3 -0
  112. package/src/utils/computeBaseUrl.ts +46 -0
  113. package/src/utils/computeContentType.ts +17 -0
  114. package/src/utils/createDebug.ts +5 -0
  115. package/src/utils/createTestApp.ts +84 -0
  116. package/src/utils/escapeValue.ts +3 -0
  117. package/src/utils/fs.ts +15 -0
  118. package/src/utils/isAdmin.ts +5 -0
  119. package/src/utils/isProduction.ts +2 -0
  120. package/src/utils/loadModules.ts +105 -0
  121. package/src/utils/randomId.ts +5 -0
  122. package/src/utils/randomUUID.ts +5 -0
  123. package/src/utils/runMigrations.ts +122 -0
  124. package/src/utils/snakeToCamelCase.ts +3 -0
  125. package/src/utils/sortBy.ts +8 -0
  126. package/src/utils/sql.ts +553 -0
  127. package/src/utils/toNodeHandler.ts +121 -0
  128. package/src/utils/types.ts +1 -0
  129. package/src/utils/waitFor.ts +5 -0
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ export { Component, type Ctor, ComponentFactory, INJECT } from "./Component.js";
2
+ export {
3
+ type InternalUserId,
4
+ type UserId,
5
+ type InternalGroupId,
6
+ type GroupId,
7
+ type Role,
8
+ type ConnectedUser,
9
+ type AdminUserId,
10
+ type Admin,
11
+ type Context,
12
+ emptyContext,
13
+ } from "./Context.js";
14
+ export { ConfigService } from "./ConfigService.js";
15
+ export { DB, DuplicateKeyError } from "./DB.js";
16
+ export { EventEmitter } from "./EventEmitter.js";
17
+ export {
18
+ PubSubService,
19
+ NodeClusterPubSubService,
20
+ initPrimary,
21
+ } from "./PubSubService.js";
22
+ export { I18nService } from "./I18nService.js";
23
+ export { Endpoint, AdminEndpoint } from "./Endpoint.js";
24
+ export { type HttpContext } from "./HttpContext.js";
25
+ export { App } from "./App.js";
26
+ export {
27
+ type Module,
28
+ type AppInit,
29
+ ModuleDefinitions,
30
+ type Route,
31
+ } from "./Module.js";
32
+ export { View, AdminView } from "./View.js";
33
+ export { Middleware } from "./Middleware.js";
34
+ export { Repository, type Page, EntityNotFoundError } from "./Repository.js";
35
+ export { TemplateService } from "./TemplateService.js";
36
+
37
+ export { createCookie, parseCookieHeader } from "./contrib/cookie.js";
38
+
39
+ export { AJV_INSTANCE } from "./utils/ajv.js";
40
+ export { camelToSnakeCase } from "./utils/camelToSnakeCase.js";
41
+ export { createDebug } from "./utils/createDebug.js";
42
+ export { createTestApp, type BaseTestContext } from "./utils/createTestApp.js";
43
+ export { escapeValue } from "./utils/escapeValue.js";
44
+ export { FS } from "./utils/fs.js";
45
+ export { isAdmin } from "./utils/isAdmin.js";
46
+ export { isProduction } from "./utils/isProduction.js";
47
+ export { randomId } from "./utils/randomId.js";
48
+ export { randomUUID } from "./utils/randomUUID.js";
49
+ export { snakeToCamelCase } from "./utils/snakeToCamelCase.js";
50
+ export { sortBy } from "./utils/sortBy.js";
51
+ export { sql, Statement } from "./utils/sql.js";
52
+ export { toNodeHandler } from "./utils/toNodeHandler.js";
53
+ export { type Brand } from "./utils/types.js";
54
+ export { waitFor } from "./utils/waitFor.js";
@@ -0,0 +1,13 @@
1
+ import { Ajv } from "ajv";
2
+ import addFormats from "ajv-formats";
3
+
4
+ // reference: https://ajv.js.org/api.html
5
+
6
+ export const AJV_INSTANCE = new Ajv({
7
+ useDefaults: true,
8
+ coerceTypes: true,
9
+ removeAdditional: true,
10
+ });
11
+
12
+ // @ts-expect-error FIXME
13
+ addFormats(AJV_INSTANCE);
@@ -0,0 +1,3 @@
1
+ export function camelToSnakeCase(str: string) {
2
+ return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
3
+ }
@@ -0,0 +1,46 @@
1
+ function removeQuotes(str: string) {
2
+ if (str.startsWith('"') && str.endsWith('"')) {
3
+ return str.slice(1, -1);
4
+ } else {
5
+ return str;
6
+ }
7
+ }
8
+
9
+ function parseForwardedHeader(header: string) {
10
+ const parts = header.split(";").map((part) => part.trim());
11
+ const parsed = {} as Record<string, string>;
12
+
13
+ for (const part of parts) {
14
+ const [key, value] = part.split("=");
15
+
16
+ if (key && value) {
17
+ parsed[key.toLowerCase()] = removeQuotes(value);
18
+ }
19
+ }
20
+
21
+ return `${parsed.proto}://${parsed.host}`;
22
+ }
23
+
24
+ export function computeBaseUrl(headers: Headers) {
25
+ const forwardedHeader = headers.get("forwarded");
26
+
27
+ if (forwardedHeader) {
28
+ const baseUrl = parseForwardedHeader(forwardedHeader);
29
+ if (baseUrl) {
30
+ return baseUrl;
31
+ }
32
+ }
33
+
34
+ const forwardedProto = headers.get("x-forwarded-proto");
35
+ const forwardedHost = headers.get("x-forwarded-host");
36
+
37
+ if (forwardedProto && forwardedHost) {
38
+ const proto = forwardedProto.split(",");
39
+ const host = forwardedHost.split(",");
40
+ return `${proto[0]}://${host[0]}`;
41
+ }
42
+
43
+ const host = headers.get("host");
44
+
45
+ return `http://${host}`;
46
+ }
@@ -0,0 +1,17 @@
1
+ const CONTENT_TYPES = new Map<string, string>([
2
+ ["html", "text/html"],
3
+ ["css", "text/css"],
4
+ ["js", "application/javascript"],
5
+ ["json", "application/json"],
6
+ ["png", "image/png"],
7
+ ["jpg", "image/jpeg"],
8
+ ["gif", "image/gif"],
9
+ ["svg", "image/svg+xml"],
10
+ ]);
11
+
12
+ export function computeContentType(path: string) {
13
+ const i = path.lastIndexOf(".");
14
+ if (i === -1) return "application/octet-stream";
15
+ const extension = path.substring(i + 1);
16
+ return CONTENT_TYPES.get(extension) || "application/octet-stream";
17
+ }
@@ -0,0 +1,5 @@
1
+ import { debuglog } from "node:util";
2
+
3
+ export function createDebug(module: string) {
4
+ return debuglog(`tymber:${module}`);
5
+ }
@@ -0,0 +1,84 @@
1
+ import { DB } from "../DB.js";
2
+ import { createServer, Server } from "node:http";
3
+ import { type AddressInfo } from "node:net";
4
+ import { type Module } from "../Module.js";
5
+ import { App } from "../App.js";
6
+ import { toNodeHandler } from "./toNodeHandler.js";
7
+
8
+ const CLOSE_DELAY_MS = 200;
9
+
10
+ export interface BaseTestContext {
11
+ baseUrl: string;
12
+ db: DB;
13
+ close: () => Promise<void>;
14
+ }
15
+
16
+ interface SharedTestContext {
17
+ httpServer: Server;
18
+ baseUrl: string;
19
+ db: DB;
20
+ }
21
+
22
+ let sharedTestContext: SharedTestContext | undefined;
23
+ let closeTimer: NodeJS.Timeout | undefined;
24
+
25
+ export async function createTestApp(
26
+ initDB: () => DB | Promise<DB>,
27
+ modules: Module[],
28
+ ): Promise<BaseTestContext> {
29
+ if (!sharedTestContext) {
30
+ const httpServer = createServer();
31
+
32
+ const [port, db] = await Promise.all([
33
+ startHttpServer(httpServer),
34
+ initDB(),
35
+ ]);
36
+ const baseUrl = `http://localhost:${port}`;
37
+
38
+ sharedTestContext = {
39
+ httpServer,
40
+ baseUrl,
41
+ db,
42
+ };
43
+ }
44
+
45
+ const { httpServer, baseUrl, db } = sharedTestContext;
46
+ const app = await App.create({
47
+ components: [db],
48
+ modules,
49
+ });
50
+
51
+ httpServer.removeAllListeners("request");
52
+ httpServer.on("request", toNodeHandler(app.fetch.bind(app)));
53
+
54
+ return {
55
+ baseUrl,
56
+ db,
57
+ async close() {
58
+ clearTimeout(closeTimer);
59
+
60
+ closeTimer = setTimeout(async () => {
61
+ if (sharedTestContext) {
62
+ const { httpServer } = sharedTestContext;
63
+ sharedTestContext = undefined;
64
+
65
+ await Promise.all([closeHttpServer(httpServer), app.close()]);
66
+ }
67
+ }, CLOSE_DELAY_MS);
68
+ },
69
+ };
70
+ }
71
+
72
+ function startHttpServer(httpServer: Server) {
73
+ return new Promise<number>((resolve) => {
74
+ httpServer.listen(0, () => {
75
+ resolve((httpServer.address() as AddressInfo).port);
76
+ });
77
+ });
78
+ }
79
+
80
+ function closeHttpServer(httpServer: Server) {
81
+ return new Promise<void>((resolve) => {
82
+ httpServer.close(() => resolve());
83
+ });
84
+ }
@@ -0,0 +1,3 @@
1
+ export function escapeValue(value: string) {
2
+ return value.replaceAll(/([~%_])/g, "~$1");
3
+ }
@@ -0,0 +1,15 @@
1
+ import { join as nodeJoin } from "node:path";
2
+ import { readdir, readFile as nodeReadFile } from "node:fs/promises";
3
+ import { createReadStream as nodeCreateReadStream } from "node:fs";
4
+
5
+ // group Node.js-specific methods
6
+ export const FS = {
7
+ join: (...paths: string[]) => nodeJoin(...paths),
8
+ createReadStream: (path: string) => nodeCreateReadStream(path),
9
+ readFile: (path: string) => nodeReadFile(path, "utf8"),
10
+ readDirRecursively: (path: string) =>
11
+ readdir(path, {
12
+ encoding: "utf8",
13
+ recursive: true,
14
+ }),
15
+ };
@@ -0,0 +1,5 @@
1
+ import { type HttpContext } from "../HttpContext.js";
2
+
3
+ export function isAdmin(ctx: HttpContext) {
4
+ return ctx.admin !== undefined;
5
+ }
@@ -0,0 +1,2 @@
1
+ export const isProduction =
2
+ process.env.NODE_ENV === "production" || process.env.ENV === "production";
@@ -0,0 +1,105 @@
1
+ import { Component, ComponentFactory, type Ctor } from "../Component.js";
2
+ import type { Module, ModuleDefinition } from "../Module.js";
3
+ import { type HttpMethod } from "../Router.js";
4
+ import { AdminEndpoint, Endpoint } from "../Endpoint.js";
5
+ import { AdminView, View } from "../View.js";
6
+ import { Middleware } from "../Middleware.js";
7
+ import { createDebug } from "./createDebug.js";
8
+
9
+ const debug = createDebug("loadModules");
10
+
11
+ export async function loadModules(
12
+ componentFactory: ComponentFactory,
13
+ modules: Module[],
14
+ ) {
15
+ const moduleDefinitions = [];
16
+
17
+ for (const module of modules) {
18
+ const moduleDefinition: ModuleDefinition = {
19
+ name: module.name,
20
+ version: module.version,
21
+ assetsDir: module.assetsDir,
22
+ adminSidebarItems: module.adminSidebarItems,
23
+
24
+ endpoints: [],
25
+ views: [],
26
+ adminEndpoints: [],
27
+ adminViews: [],
28
+ middlewares: [],
29
+ };
30
+
31
+ const appInit = {
32
+ component<T extends Component>(ctor: Ctor<T>) {
33
+ debug("adding component %s", ctor.name);
34
+ componentFactory.register(ctor);
35
+ },
36
+
37
+ endpoint: (method: HttpMethod, path: string, ctor: Ctor<Endpoint>) => {
38
+ debug("adding endpoint %s %s", method, path);
39
+ componentFactory.register(ctor, (handler) => {
40
+ moduleDefinition.endpoints.push({
41
+ method,
42
+ path,
43
+ handlerName: ctor.name,
44
+ handler,
45
+ });
46
+ });
47
+ },
48
+
49
+ view: (path: string, ctor: Ctor<View>) => {
50
+ debug("adding view %s", path);
51
+ componentFactory.register(ctor, (handler) => {
52
+ moduleDefinition.views.push({
53
+ method: "GET",
54
+ path,
55
+ handlerName: ctor.name,
56
+ handler,
57
+ });
58
+ });
59
+ },
60
+
61
+ adminEndpoint: (
62
+ method: HttpMethod,
63
+ path: string,
64
+ ctor: Ctor<AdminEndpoint>,
65
+ ) => {
66
+ debug("adding admin endpoint %s %s", method, path);
67
+ componentFactory.register(ctor, (handler) => {
68
+ moduleDefinition.adminEndpoints.push({
69
+ method,
70
+ path,
71
+ handlerName: ctor.name,
72
+ handler,
73
+ });
74
+ });
75
+ },
76
+
77
+ adminView: (path: string, ctor: Ctor<AdminView>) => {
78
+ debug("adding admin view %s", path);
79
+ componentFactory.register(ctor, (handler) => {
80
+ moduleDefinition.adminViews.push({
81
+ method: "GET",
82
+ path,
83
+ handlerName: ctor.name,
84
+ handler,
85
+ });
86
+ });
87
+ },
88
+
89
+ middleware: (ctor: Ctor<Middleware>) => {
90
+ debug("adding middleware %s", ctor.name);
91
+
92
+ componentFactory.register(ctor, (instance) => {
93
+ moduleDefinition.middlewares.push(instance);
94
+ });
95
+ },
96
+ };
97
+
98
+ debug("loading module %s", module.name);
99
+ module.init(appInit);
100
+
101
+ moduleDefinitions.push(moduleDefinition);
102
+ }
103
+
104
+ return moduleDefinitions;
105
+ }
@@ -0,0 +1,5 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ export function randomId() {
4
+ return randomBytes(8).toString("hex");
5
+ }
@@ -0,0 +1,5 @@
1
+ import { randomUUID as nodeRandomUUID } from "node:crypto";
2
+
3
+ export function randomUUID() {
4
+ return nodeRandomUUID();
5
+ }
@@ -0,0 +1,122 @@
1
+ import type { ModuleDefinition } from "../Module.js";
2
+ import { sortBy } from "./sortBy.js";
3
+ import { createDebug } from "./createDebug.js";
4
+ import type { DB } from "../DB.js";
5
+ import { emptyContext } from "../Context.js";
6
+ import { FS } from "./fs.js";
7
+ import { sql } from "./sql.js";
8
+
9
+ const debug = createDebug("runMigrations");
10
+
11
+ // example: 0001-create-users-table.sql
12
+ const MIGRATION_REGEX = /^(\d+)-(.*)\.sql$/;
13
+
14
+ interface Migration {
15
+ module: string;
16
+ id: number;
17
+ name: string;
18
+ sql: string;
19
+ }
20
+
21
+ export async function runMigrations(db: DB, modules: ModuleDefinition[]) {
22
+ const ctx = emptyContext();
23
+
24
+ debug("creating migrations table");
25
+ await db.createMigrationsTable(ctx);
26
+
27
+ for (const module of modules) {
28
+ if (!module.assetsDir) {
29
+ continue;
30
+ }
31
+
32
+ const migrationFiles: Migration[] = [];
33
+
34
+ for (const { filename, absolutePath } of await readMigrationFiles(
35
+ db,
36
+ module,
37
+ )) {
38
+ let match = MIGRATION_REGEX.exec(filename);
39
+ if (match) {
40
+ migrationFiles.push({
41
+ module: module.name,
42
+ id: parseInt(match[1], 10),
43
+ name: match[2],
44
+ sql: await FS.readFile(absolutePath),
45
+ });
46
+ }
47
+ }
48
+
49
+ sortBy(migrationFiles, "id");
50
+
51
+ for (const migration of migrationFiles) {
52
+ await db.startTransaction(ctx, async () => {
53
+ const result = await db.query(
54
+ ctx,
55
+ sql.select().from("t_migrations").where({
56
+ id: migration.id,
57
+ module: migration.module,
58
+ }),
59
+ );
60
+
61
+ if (result.length === 1) {
62
+ debug(
63
+ "migration #%d of module %s already applied",
64
+ migration.id,
65
+ migration.module,
66
+ );
67
+ return;
68
+ }
69
+
70
+ debug(
71
+ "applying migration #%d of module %s",
72
+ migration.id,
73
+ migration.module,
74
+ );
75
+ await db.exec(ctx, sql.rawStatement(migration.sql));
76
+
77
+ await db.run(
78
+ ctx,
79
+ sql
80
+ .insert()
81
+ .into("t_migrations")
82
+ .values([
83
+ {
84
+ module: migration.module,
85
+ id: migration.id,
86
+ name: migration.name,
87
+ run_at: new Date(),
88
+ },
89
+ ]),
90
+ );
91
+ });
92
+ }
93
+ }
94
+ }
95
+
96
+ async function readMigrationFiles(db: DB, module: ModuleDefinition) {
97
+ const assetsDir = module.assetsDir;
98
+ if (!assetsDir) {
99
+ return [];
100
+ }
101
+ try {
102
+ const dbSpecificPath = FS.join(assetsDir, "migrations", db.name);
103
+ debug("reading files from %s", dbSpecificPath);
104
+ const filenames = await FS.readDirRecursively(dbSpecificPath);
105
+ return filenames.map((filename) => ({
106
+ filename,
107
+ absolutePath: FS.join(assetsDir, "migrations", db.name, filename),
108
+ }));
109
+ } catch (e) {
110
+ try {
111
+ const commonPath = FS.join(assetsDir, "migrations");
112
+ debug("reading files from %s", commonPath);
113
+ const filenames = await FS.readDirRecursively(commonPath);
114
+ return filenames.map((filename) => ({
115
+ filename,
116
+ absolutePath: FS.join(assetsDir, "migrations", filename),
117
+ }));
118
+ } catch (e) {
119
+ return [];
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,3 @@
1
+ export function snakeToCamelCase(str: string) {
2
+ return str.replace(/_([a-z])/g, (letter) => letter[1].toUpperCase());
3
+ }
@@ -0,0 +1,8 @@
1
+ export function sortBy<T>(array: T[], field: keyof T, field2?: keyof T): T[] {
2
+ return array.sort((a, b) => {
3
+ if (a[field] === b[field] && field2) {
4
+ return a[field2] < b[field2] ? -1 : 1;
5
+ }
6
+ return a[field] < b[field] ? -1 : 1;
7
+ });
8
+ }