squilo 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -0
- package/biome.json +34 -0
- package/package.json +44 -0
- package/src/index.ts +1 -0
- package/src/pipes/auth/index.ts +24 -0
- package/src/pipes/auth/strategies/index.ts +2 -0
- package/src/pipes/auth/strategies/msal.ts +133 -0
- package/src/pipes/auth/strategies/userAndPassword.ts +10 -0
- package/src/pipes/connect/index.ts +78 -0
- package/src/pipes/execute/index.ts +29 -0
- package/src/pipes/index.ts +1 -0
- package/src/pipes/input/index.ts +19 -0
- package/src/pipes/output/index.ts +4 -0
- package/src/pipes/output/strategies/console.ts +7 -0
- package/src/pipes/output/strategies/index.ts +4 -0
- package/src/pipes/output/strategies/json.spec.ts +38 -0
- package/src/pipes/output/strategies/json.ts +9 -0
- package/src/pipes/output/strategies/merge.spec.ts +51 -0
- package/src/pipes/output/strategies/merge.ts +11 -0
- package/src/pipes/output/strategies/xls.spec.ts +64 -0
- package/src/pipes/output/strategies/xls.ts +79 -0
- package/src/pipes/retrieve/index.ts +47 -0
- package/src/pipes/server/index.ts +10 -0
- package/src/pipes/server/types.ts +3 -0
- package/src/pool/index.ts +43 -0
- package/test/connect.spec.ts +119 -0
- package/test/container/container.spec.ts +33 -0
- package/test/container/container.ts +22 -0
- package/test/container/setup/databases.spec.ts +24 -0
- package/test/container/setup/databases.ts +77 -0
- package/test/container/setup/users.spec.ts +25 -0
- package/test/container/setup/users.ts +54 -0
- package/test/index.spec.ts +68 -0
- package/test/input.spec.ts +64 -0
- package/tsconfig.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Squilo
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
Squilo is a Bun-first library for orchestrating SQL Server connections, authentication, and script execution with modern TypeScript patterns.
|
|
8
|
+
|
|
9
|
+
## Getting Started
|
|
10
|
+
|
|
11
|
+
### Initialize a Bun Project
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun init
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Add Squilo as a Production Dependency
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bun add squilo
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage Example
|
|
24
|
+
|
|
25
|
+
### Exportable Servers (Production, Dev, ...)
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// src/server.ts
|
|
29
|
+
import { Server } from "squilo";
|
|
30
|
+
|
|
31
|
+
export const ProdServer = Server({
|
|
32
|
+
server: "prod-host",
|
|
33
|
+
port: 1433,
|
|
34
|
+
options: { encrypt: true }
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const DevServer = Server({
|
|
38
|
+
server: "localhost",
|
|
39
|
+
port: 1433,
|
|
40
|
+
options: { encrypt: false }
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Exportable Authentications
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// src/auth.ts
|
|
48
|
+
import { UserAndPassword } from "squilo/pipes/auth/strategies";
|
|
49
|
+
|
|
50
|
+
export const ProdAuth = UserAndPassword("prod_user", "prod_pass");
|
|
51
|
+
export const DevAuth = UserAndPassword("sa", "your_dev_password");
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Create Scripts
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// scripts/fixEmails.ts
|
|
58
|
+
import { ProdServer } from "../src/server";
|
|
59
|
+
import { ProdAuth } from "../src/auth";
|
|
60
|
+
|
|
61
|
+
await ProdServer.Auth(ProdAuth)
|
|
62
|
+
.Connect(["db1", "db2"])
|
|
63
|
+
.Execute(async (transaction) => {
|
|
64
|
+
await transaction.request().query`
|
|
65
|
+
UPDATE Users SET Email = RTRIM(Email)
|
|
66
|
+
`;
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Run Scripts Using Bun
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
bun run scripts/fixEmails.ts
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Advanced Usage
|
|
77
|
+
|
|
78
|
+
- Use `.Input()` to provide dynamic input for scripts
|
|
79
|
+
- Use `.Retrieve()` to fetch results from databases
|
|
80
|
+
- Use `.Output()` strategies to merge or format results
|
|
81
|
+
|
|
82
|
+
## Reference
|
|
83
|
+
|
|
84
|
+
See the [test suite](./test/) for more advanced usage and orchestration patterns.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
This project was created using `bun init` in bun v1.2.20. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
package/biome.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
|
|
3
|
+
"vcs": {
|
|
4
|
+
"enabled": false,
|
|
5
|
+
"clientKind": "git",
|
|
6
|
+
"useIgnoreFile": false
|
|
7
|
+
},
|
|
8
|
+
"files": {
|
|
9
|
+
"ignoreUnknown": false
|
|
10
|
+
},
|
|
11
|
+
"formatter": {
|
|
12
|
+
"enabled": true,
|
|
13
|
+
"indentStyle": "tab"
|
|
14
|
+
},
|
|
15
|
+
"linter": {
|
|
16
|
+
"enabled": true,
|
|
17
|
+
"rules": {
|
|
18
|
+
"recommended": true
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"javascript": {
|
|
22
|
+
"formatter": {
|
|
23
|
+
"quoteStyle": "double"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"assist": {
|
|
27
|
+
"enabled": true,
|
|
28
|
+
"actions": {
|
|
29
|
+
"source": {
|
|
30
|
+
"organizeImports": "on"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "squilo",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"module": "index.ts",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/douglasdasilvasousa/Squilo.git"
|
|
12
|
+
},
|
|
13
|
+
"author": {
|
|
14
|
+
"name": "Douglas da Silva Sousa",
|
|
15
|
+
"email": "douglass.sousa@outlook.com.br"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.ts",
|
|
19
|
+
"./auth": "./src/pipes/auth/strategies/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "bun test",
|
|
23
|
+
"test:watch": "bun test --watch",
|
|
24
|
+
"test:debug": "bun test --inspect"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@biomejs/biome": "2.2.0",
|
|
28
|
+
"@faker-js/faker": "^9.9.0",
|
|
29
|
+
"@types/bun": "latest",
|
|
30
|
+
"@types/xlsx": "^0.0.36",
|
|
31
|
+
"testcontainers": "^11.5.1"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"typescript": "^5"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@azure/msal-node": "^3.7.1",
|
|
38
|
+
"@types/mssql": "^9.1.7",
|
|
39
|
+
"mssql": "^11.0.1",
|
|
40
|
+
"open": "^10.2.0",
|
|
41
|
+
"xlsx": "^0.18.5"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT"
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./pipes/server"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { config } from 'mssql';
|
|
2
|
+
import type { ServerConfig } from "../server/types";
|
|
3
|
+
import type { ConnectionOptions } from "../connect";
|
|
4
|
+
import { Pool } from "../../pool";
|
|
5
|
+
import { type ConnectionChain, Connect } from "../connect";
|
|
6
|
+
|
|
7
|
+
export type AuthStrategy = (config: ServerConfig) => config;
|
|
8
|
+
|
|
9
|
+
export type AuthenticationChain = {
|
|
10
|
+
Connect(database: string): ConnectionChain;
|
|
11
|
+
Connect(databases: string[], concurrent?: number): ConnectionChain;
|
|
12
|
+
Connect(options: ConnectionOptions, concurrent?: number): ConnectionChain;
|
|
13
|
+
Close(): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const Auth = (config: ServerConfig) => (strategy: AuthStrategy): AuthenticationChain => {
|
|
17
|
+
const configWithAuth = strategy(config);
|
|
18
|
+
const pool = Pool(configWithAuth);
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
Connect: Connect(pool),
|
|
22
|
+
Close: () => pool.closeAll(),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AuthenticationResult,
|
|
3
|
+
type Configuration,
|
|
4
|
+
type ICachePlugin,
|
|
5
|
+
type InteractiveRequest,
|
|
6
|
+
LogLevel,
|
|
7
|
+
type NodeAuthOptions,
|
|
8
|
+
PublicClientApplication,
|
|
9
|
+
type TokenCache,
|
|
10
|
+
type TokenCacheContext,
|
|
11
|
+
} from "@azure/msal-node";
|
|
12
|
+
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import type { ServerConfig } from "../../server/types";
|
|
15
|
+
import type { AuthStrategy } from "..";
|
|
16
|
+
import { cwd } from "process";
|
|
17
|
+
|
|
18
|
+
const SCOPES = ["https://database.windows.net//.default"];
|
|
19
|
+
|
|
20
|
+
const cacheAccess = (hash: string) => {
|
|
21
|
+
const cacheFilePath = path.join(cwd(), `${hash}.json`);
|
|
22
|
+
|
|
23
|
+
const before = async (cacheContext: TokenCacheContext) => {
|
|
24
|
+
try {
|
|
25
|
+
const cacheFile = await Bun.file(cacheFilePath).text();
|
|
26
|
+
cacheContext.tokenCache.deserialize(cacheFile);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
await Bun.write(cacheFilePath, "");
|
|
29
|
+
cacheContext.tokenCache.deserialize("");
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const after = async (cacheContext: TokenCacheContext) => {
|
|
34
|
+
if (cacheContext.cacheHasChanged) {
|
|
35
|
+
try {
|
|
36
|
+
await Bun.write(cacheFilePath, cacheContext.tokenCache.serialize());
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error(err);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
beforeCacheAccess: before,
|
|
45
|
+
afterCacheAccess: after,
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const msalConfig = (config: NodeAuthOptions, cachePlugin: ICachePlugin) => ({
|
|
50
|
+
auth: config,
|
|
51
|
+
cache: {
|
|
52
|
+
cachePlugin,
|
|
53
|
+
},
|
|
54
|
+
system: {
|
|
55
|
+
loggerOptions: {
|
|
56
|
+
loggerCallback(loglevel, message) {
|
|
57
|
+
console.log(message);
|
|
58
|
+
},
|
|
59
|
+
piiLoggingEnabled: false,
|
|
60
|
+
logLevel: LogLevel.Error,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
} as Configuration);
|
|
64
|
+
|
|
65
|
+
export const GetToken = async (config: NodeAuthOptions) => {
|
|
66
|
+
const tenantId = config.authority?.replace("https://login.microsoftonline.com/", "");
|
|
67
|
+
const clientId = config.clientId;
|
|
68
|
+
|
|
69
|
+
const pca: PublicClientApplication = new PublicClientApplication(
|
|
70
|
+
msalConfig(config, cacheAccess(`${tenantId}-${clientId}`)),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const tokenCache: TokenCache = pca.getTokenCache();
|
|
74
|
+
|
|
75
|
+
async function getAccount() {
|
|
76
|
+
return await tokenCache.getAllAccounts();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const accounts = await getAccount();
|
|
80
|
+
let result: AuthenticationResult | null;
|
|
81
|
+
|
|
82
|
+
if (accounts.length > 0 && accounts[0]) {
|
|
83
|
+
result = await pca.acquireTokenSilent({
|
|
84
|
+
scopes: SCOPES,
|
|
85
|
+
account: accounts[0],
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return result?.accessToken;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const interactiveRequest: InteractiveRequest = {
|
|
92
|
+
scopes: SCOPES,
|
|
93
|
+
openBrowser: async (url) => {
|
|
94
|
+
const { default: open } = await import("open");
|
|
95
|
+
open(url);
|
|
96
|
+
},
|
|
97
|
+
successTemplate: `
|
|
98
|
+
<html lang="HTML5">
|
|
99
|
+
<head>
|
|
100
|
+
<title>Authentication Success</title>
|
|
101
|
+
</head>
|
|
102
|
+
<script>
|
|
103
|
+
setTimeout(() => {
|
|
104
|
+
window.close();
|
|
105
|
+
}, 1000);
|
|
106
|
+
</script>
|
|
107
|
+
<body>
|
|
108
|
+
<h1>Authentication Success</h1>
|
|
109
|
+
<p>This window will be closed now</p>
|
|
110
|
+
</body>
|
|
111
|
+
</html>
|
|
112
|
+
`,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
result = await pca.acquireTokenInteractive(interactiveRequest);
|
|
116
|
+
|
|
117
|
+
return result?.accessToken;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const ActiveDirectoryAccessToken = async (config: NodeAuthOptions): Promise<AuthStrategy> => {
|
|
121
|
+
const accessToken = await GetToken(config);
|
|
122
|
+
return (config: ServerConfig) => {
|
|
123
|
+
return {
|
|
124
|
+
...config,
|
|
125
|
+
authentication: {
|
|
126
|
+
type: 'azure-active-directory-access-token',
|
|
127
|
+
options: {
|
|
128
|
+
token: accessToken
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AuthStrategy } from ".."
|
|
2
|
+
import type { ServerConfig } from "../../server/types"
|
|
3
|
+
|
|
4
|
+
export const UserAndPassword = (username: string, password: string): AuthStrategy => (config: ServerConfig) => {
|
|
5
|
+
return {
|
|
6
|
+
...config,
|
|
7
|
+
user: username,
|
|
8
|
+
password
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ConnectionPool, Transaction } from "mssql";
|
|
2
|
+
import type { Pool } from "../../pool";
|
|
3
|
+
import { type InputChain, Input } from "../input";
|
|
4
|
+
import { Execute } from "../execute";
|
|
5
|
+
import { type RetrieveChain, Retrieve } from "../retrieve";
|
|
6
|
+
|
|
7
|
+
export type ConnectOverloads = {
|
|
8
|
+
(database: string): ConnectionChain;
|
|
9
|
+
(databases: string[], concurrent?: number): ConnectionChain;
|
|
10
|
+
(options: ConnectionOptions, concurrent?: number): ConnectionChain;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ConnectionOptions = {
|
|
14
|
+
database: string;
|
|
15
|
+
query: `SELECT${string}FROM${string}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type DatabaseConnection = {
|
|
19
|
+
database: string;
|
|
20
|
+
connection: Promise<ConnectionPool>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ConnectionChain = {
|
|
24
|
+
Execute(fn: (transaction: Transaction, database: string) => Promise<void>): Promise<void>;
|
|
25
|
+
Retrieve<TResult>(fn: (transaction: Transaction, database: string) => Promise<TResult>): RetrieveChain<TResult>;
|
|
26
|
+
Input<TParam>(fn: () => TParam): InputChain<TParam>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const Connect = (pool: Pool): ConnectOverloads => (param: string | string[] | ConnectionOptions, concurrent?: number): ConnectionChain => {
|
|
30
|
+
let connections$: AsyncGenerator<DatabaseConnection[]>;
|
|
31
|
+
let databases$: Promise<string[]>;
|
|
32
|
+
|
|
33
|
+
async function *connections(databases$: Promise<string[]>, concurrent: number = Number.MAX_VALUE) {
|
|
34
|
+
const databases = await databases$;
|
|
35
|
+
|
|
36
|
+
const databases_result_chunks = Array.from(
|
|
37
|
+
{length: Math.ceil(databases.length / concurrent)},
|
|
38
|
+
(_, i) => databases.slice(i * concurrent, (i + 1) * concurrent)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
for (const databases_result_chunk of databases_result_chunks) {
|
|
42
|
+
yield databases_result_chunk.map(database => ({
|
|
43
|
+
database,
|
|
44
|
+
connection: pool.connect({ database })
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typeof param === 'string') {
|
|
50
|
+
databases$ = Promise.resolve([param]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
else if (Array.isArray(param)) {
|
|
54
|
+
databases$ = Promise.resolve(param);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
else if (typeof param === 'object' && 'query' in param) {
|
|
58
|
+
databases$ = pool
|
|
59
|
+
.connect({ database: param.database, arrayRowMode: true })
|
|
60
|
+
.then(conn => conn
|
|
61
|
+
.request()
|
|
62
|
+
.query<string[]>(param.query)
|
|
63
|
+
)
|
|
64
|
+
.then(result => result.recordset.flat())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
else {
|
|
68
|
+
throw new Error("Invalid parameter");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
connections$ = connections(databases$, concurrent);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
Input: Input(connections$),
|
|
75
|
+
Execute: Execute(connections$, null),
|
|
76
|
+
Retrieve: Retrieve(connections$, null)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Transaction } from 'mssql';
|
|
2
|
+
import type { DatabaseConnection } from "../connect";
|
|
3
|
+
|
|
4
|
+
export const Execute = <TParam>(
|
|
5
|
+
connections$: AsyncGenerator<DatabaseConnection[]>,
|
|
6
|
+
input: TParam
|
|
7
|
+
) => {
|
|
8
|
+
return async (
|
|
9
|
+
fn: (transaction: Transaction, database: string, params: TParam) => Promise<void>
|
|
10
|
+
): Promise<void> => {
|
|
11
|
+
const executeFn = async (dc: DatabaseConnection): Promise<void> => {
|
|
12
|
+
const opened = await dc.connection;
|
|
13
|
+
const transaction = opened.transaction()
|
|
14
|
+
try {
|
|
15
|
+
await transaction.begin();
|
|
16
|
+
await fn(transaction, dc.database, input);
|
|
17
|
+
await transaction.commit();
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
await transaction.rollback();
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
for await (const connectionBatch of connections$) {
|
|
25
|
+
const executions = connectionBatch.map(executeFn);
|
|
26
|
+
await Promise.allSettled(executions);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Server } from './server/index'
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Transaction } from "mssql";
|
|
2
|
+
import { Execute } from "../execute";
|
|
3
|
+
import type { DatabaseConnection } from "../connect";
|
|
4
|
+
import { Retrieve, type RetrieveChain } from "../retrieve";
|
|
5
|
+
|
|
6
|
+
export type InputChain<TParam> = {
|
|
7
|
+
Execute(fn: (transaction: Transaction, database: string, params: TParam) => Promise<void>): Promise<void>;
|
|
8
|
+
Retrieve<TResult>(fn: (transaction: Transaction, database: string, params: TParam) => Promise<TResult>): RetrieveChain<TResult>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const Input = (connections: AsyncGenerator<DatabaseConnection[]>) => {
|
|
12
|
+
return <TParam>(fn: () => TParam): InputChain<TParam> => {
|
|
13
|
+
const params = fn();
|
|
14
|
+
return {
|
|
15
|
+
Execute: Execute(connections, params),
|
|
16
|
+
Retrieve: Retrieve(connections, params)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type OutputStrategy<TReturn, TOutput = void> = (data: ReadableStream<Record<string, TReturn>>) => Promise<TOutput>;
|
|
2
|
+
|
|
3
|
+
export const Output = <TReturn, TOutput = void>(data: ReadableStream<Record<string, TReturn>>) => (strategy: OutputStrategy<TReturn, TOutput>): Promise<TOutput> => strategy(data);
|
|
4
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { JsonOutputStrategy } from "./index";
|
|
3
|
+
|
|
4
|
+
describe("JsonOutputStrategy", () => {
|
|
5
|
+
test("should format data as JSON with database keys", async () => {
|
|
6
|
+
const mockData = new ReadableStream({
|
|
7
|
+
start(controller) {
|
|
8
|
+
controller.enqueue({ "database1": [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }] });
|
|
9
|
+
controller.enqueue({ "database2": [{ id: 3, name: "Charlie" }] });
|
|
10
|
+
controller.close();
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const strategy = JsonOutputStrategy();
|
|
15
|
+
const result = await strategy(mockData);
|
|
16
|
+
|
|
17
|
+
expect(typeof result).toBe("object");
|
|
18
|
+
expect(result).toEqual({
|
|
19
|
+
"database1": [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }],
|
|
20
|
+
"database2": [{ id: 3, name: "Charlie" }]
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("should handle empty data", async () => {
|
|
25
|
+
const mockData = new ReadableStream({
|
|
26
|
+
start(controller) {
|
|
27
|
+
controller.enqueue({});
|
|
28
|
+
controller.close();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const strategy = JsonOutputStrategy();
|
|
33
|
+
const result = await strategy(mockData);
|
|
34
|
+
|
|
35
|
+
expect(typeof result).toBe("object");
|
|
36
|
+
expect(result).toEqual({});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { OutputStrategy } from '../index';
|
|
2
|
+
|
|
3
|
+
export const JsonOutputStrategy = <TData>(): OutputStrategy<TData, Record<string, TData[]>> => async (result) => {
|
|
4
|
+
const data: Record<string, TData[]> = {};
|
|
5
|
+
for await (const item of result) {
|
|
6
|
+
Object.assign(data, item);
|
|
7
|
+
}
|
|
8
|
+
return data;
|
|
9
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { MergeOutputStrategy } from "./index";
|
|
3
|
+
|
|
4
|
+
describe("MergeOutputStrategy", () => {
|
|
5
|
+
test("should merge array data from multiple sources", async () => {
|
|
6
|
+
const mockData = new ReadableStream({
|
|
7
|
+
start(controller) {
|
|
8
|
+
controller.enqueue({"database1": [1, 2, 3, 4, 5]});
|
|
9
|
+
controller.enqueue({"database2": [6, 7, 8, 9, 10]});
|
|
10
|
+
controller.close();
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const strategy = MergeOutputStrategy<number[]>();
|
|
15
|
+
const result = await strategy(mockData);
|
|
16
|
+
expect(result).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("should handle empty arrays", async () => {
|
|
20
|
+
const mockData = new ReadableStream({
|
|
21
|
+
start(controller) {
|
|
22
|
+
controller.enqueue({"database1": []});
|
|
23
|
+
controller.enqueue({"database2": []});
|
|
24
|
+
controller.close();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const strategy = MergeOutputStrategy<number[]>();
|
|
29
|
+
const result = await strategy(mockData);
|
|
30
|
+
|
|
31
|
+
expect(result).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("should merge object arrays", async () => {
|
|
35
|
+
const mockData = new ReadableStream({
|
|
36
|
+
start(controller) {
|
|
37
|
+
controller.enqueue({"database1": [{ id: 1, name: "Alice" }]});
|
|
38
|
+
controller.enqueue({"database2": [{ id: 2, name: "Bob" }]});
|
|
39
|
+
controller.close();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const strategy = MergeOutputStrategy<{ id: number; name: string }[]>();
|
|
44
|
+
const result = await strategy(mockData);
|
|
45
|
+
|
|
46
|
+
expect(result).toEqual([
|
|
47
|
+
{ id: 1, name: "Alice" },
|
|
48
|
+
{ id: 2, name: "Bob" }
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { OutputStrategy } from '../index';
|
|
2
|
+
|
|
3
|
+
export const MergeOutputStrategy = <TData extends Array<unknown>, TMerged = TData extends Array<infer TItem> ? TItem : TData>(): OutputStrategy<TData, TMerged[]> => async (result) => {
|
|
4
|
+
const data: TMerged[] = [];
|
|
5
|
+
for await (const item of result) {
|
|
6
|
+
Object.values(item).forEach((value) => {
|
|
7
|
+
data.push(...value as TMerged[]);
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
return data;
|
|
11
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { XlsOutputStrategy } from "./index";
|
|
3
|
+
|
|
4
|
+
describe("XlsOutputStrategy", () => {
|
|
5
|
+
test("should generate XLS file with separate sheets", async () => {
|
|
6
|
+
const mockData = new ReadableStream({
|
|
7
|
+
start(controller) {
|
|
8
|
+
controller.enqueue({
|
|
9
|
+
"database1": [{ id: 1, name: "Alice", age: 30 }, { id: 2, name: "Bob", age: 25 }],
|
|
10
|
+
"database2": [{ id: 3, name: "Charlie", age: 35 }]
|
|
11
|
+
});
|
|
12
|
+
controller.close();
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const testFile = "/tmp/test-separate.xlsx";
|
|
17
|
+
const strategy = XlsOutputStrategy(false, testFile);
|
|
18
|
+
await strategy(mockData);
|
|
19
|
+
|
|
20
|
+
expect(await Bun.file(testFile).exists()).toBe(true);
|
|
21
|
+
|
|
22
|
+
// Clean up
|
|
23
|
+
await Bun.file(testFile).delete();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("should generate XLS file with combined sheet when unique=true", async () => {
|
|
27
|
+
const mockData = new ReadableStream({
|
|
28
|
+
start(controller) {
|
|
29
|
+
controller.enqueue({
|
|
30
|
+
"database1": [{ id: 1, name: "Alice", age: 30 }],
|
|
31
|
+
"database2": [{ id: 2, name: "Bob", age: 25 }]
|
|
32
|
+
});
|
|
33
|
+
controller.close();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const testFile = "/tmp/test-combined.xlsx";
|
|
38
|
+
const strategy = XlsOutputStrategy(true, testFile);
|
|
39
|
+
await strategy(mockData);
|
|
40
|
+
|
|
41
|
+
expect(await Bun.file(testFile).exists()).toBe(true);
|
|
42
|
+
|
|
43
|
+
// Clean up
|
|
44
|
+
await Bun.file(testFile).delete();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("should create empty sheet when no data provided", async () => {
|
|
48
|
+
const mockData = new ReadableStream({
|
|
49
|
+
start(controller) {
|
|
50
|
+
controller.enqueue({});
|
|
51
|
+
controller.close();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const testFile = "/tmp/test-empty.xlsx";
|
|
56
|
+
const strategy = XlsOutputStrategy(false, testFile);
|
|
57
|
+
await strategy(mockData);
|
|
58
|
+
|
|
59
|
+
expect(await Bun.file(testFile).exists()).toBe(true);
|
|
60
|
+
|
|
61
|
+
// Clean up
|
|
62
|
+
await Bun.file(testFile).delete();
|
|
63
|
+
});
|
|
64
|
+
});
|