@jd-data-limited/easy-fm 4.1.14 → 5.0.0-beta.1
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 +60 -197
- package/dist/bin/generateTypes.js +1 -1
- package/dist/bin/stressSearch.d.ts +2 -0
- package/dist/bin/stressSearch.js +243 -0
- package/dist/connection/CookieJar.d.ts +19 -0
- package/dist/connection/CookieJar.js +99 -0
- package/dist/connection/FMHost.d.ts +24 -8
- package/dist/connection/FMHost.js +28 -8
- package/dist/connection/HostBase.d.ts +4 -2
- package/dist/connection/Session.d.ts +34 -0
- package/dist/connection/Session.js +109 -0
- package/dist/connection/database.d.ts +34 -31
- package/dist/connection/database.js +72 -133
- package/dist/connection/databaseBase.d.ts +22 -10
- package/dist/connection/databaseConstantSession.d.ts +15 -0
- package/dist/connection/databaseConstantSession.js +70 -0
- package/dist/connection/databaseSessionPool.d.ts +14 -0
- package/dist/connection/databaseSessionPool.js +148 -0
- package/dist/index.d.ts +18 -1
- package/dist/index.js +12 -0
- package/dist/layouts/layout.d.ts +5 -3
- package/dist/layouts/layout.js +11 -15
- package/dist/layouts/layoutBase.d.ts +3 -2
- package/dist/layouts/layoutRecordManager.d.ts +6 -15
- package/dist/layouts/layoutRecordManager.js +6 -15
- package/dist/models/apiResults.d.ts +208 -93
- package/dist/models/apiResults.js +95 -25
- package/dist/records/field.d.ts +17 -2
- package/dist/records/field.js +161 -237
- package/dist/records/getOperations/recordGetOperation.d.ts +7 -4
- package/dist/records/getOperations/recordGetOperation.js +15 -18
- package/dist/records/layoutRecord.d.ts +24 -7
- package/dist/records/layoutRecord.js +68 -73
- package/dist/records/portal.d.ts +0 -4
- package/dist/records/portal.js +0 -4
- package/dist/records/recordBase.d.ts +3 -2
- package/dist/types.d.ts +41 -17
- package/dist/types.js +12 -0
- package/dist/utils/addHeaders.d.ts +5 -0
- package/dist/utils/addHeaders.js +24 -0
- package/dist/utils/query.d.ts +7 -0
- package/dist/utils/query.js +5 -0
- package/package.json +33 -26
|
@@ -1,22 +1,26 @@
|
|
|
1
|
-
import { Database } from './database.js';
|
|
1
|
+
import { type Database } from './database.js';
|
|
2
2
|
import { type HostBase } from './HostBase.js';
|
|
3
|
-
import { type databaseOptionsWithExternalSources,
|
|
3
|
+
import { type databaseOptionsWithExternalSources, FMHostMetadata, type loginOptionsClaris, type loginOptionsFileMaker, type loginOptionsOAuth } from '../types.js';
|
|
4
4
|
import { type DatabaseStructure } from '../databaseStructure.js';
|
|
5
5
|
import { type Moment } from 'moment';
|
|
6
|
+
import z from 'zod';
|
|
7
|
+
import { type DatabaseProtocol } from './Session.js';
|
|
6
8
|
/**
|
|
7
9
|
* Represents a FileMaker host.
|
|
8
|
-
* @implements {HostBase}
|
|
9
10
|
*/
|
|
10
11
|
export default class FMHost implements HostBase {
|
|
11
12
|
readonly hostname: string;
|
|
12
13
|
readonly timezoneOffsetFunc: (moment: Moment) => number;
|
|
13
14
|
readonly verify: boolean;
|
|
14
|
-
readonly protocol:
|
|
15
|
-
_metadata: FMHostMetadata | null;
|
|
15
|
+
readonly protocol: DatabaseProtocol;
|
|
16
|
+
_metadata: z.infer<typeof FMHostMetadata> | null;
|
|
16
17
|
constructor(_hostname: string, timezoneOffset?: (moment: Moment) => number, verify?: boolean);
|
|
17
|
-
get metadata(): FMHostMetadata
|
|
18
|
+
get metadata(): z.infer<typeof FMHostMetadata>;
|
|
19
|
+
/** FileMaker host date format converted to Moment-compatible tokens. */
|
|
18
20
|
get dateFormat(): string;
|
|
21
|
+
/** FileMaker host time format. */
|
|
19
22
|
get timeFormat(): string;
|
|
23
|
+
/** FileMaker host timestamp format converted to Moment-compatible tokens. */
|
|
20
24
|
get timeStampFormat(): string;
|
|
21
25
|
/**
|
|
22
26
|
* Retrieves a list of databases from the FileMaker Server.
|
|
@@ -25,7 +29,9 @@ export default class FMHost implements HostBase {
|
|
|
25
29
|
* @throws {FMError} If the request to the FileMaker Server fails or if the response contains an error.
|
|
26
30
|
* @returns {Promise<any[]>} A promise that resolves to an array of database objects if successful.
|
|
27
31
|
*/
|
|
28
|
-
listDatabases(credentials?: loginOptionsOAuth | loginOptionsFileMaker | loginOptionsClaris): Promise<
|
|
32
|
+
listDatabases(credentials?: loginOptionsOAuth | loginOptionsFileMaker | loginOptionsClaris): Promise<{
|
|
33
|
+
name: string;
|
|
34
|
+
}[]>;
|
|
29
35
|
/**
|
|
30
36
|
* Creates a new database connection with the specified options.
|
|
31
37
|
*
|
|
@@ -34,5 +40,15 @@ export default class FMHost implements HostBase {
|
|
|
34
40
|
* @return {Database<T>} A new Database instance.
|
|
35
41
|
*/
|
|
36
42
|
database<T extends DatabaseStructure>(data: databaseOptionsWithExternalSources): Database<T>;
|
|
37
|
-
|
|
43
|
+
/** Fetch and cache FileMaker host product metadata. */
|
|
44
|
+
getMetadata(): Promise<{
|
|
45
|
+
productInfo: {
|
|
46
|
+
name: string;
|
|
47
|
+
dateFormat: string;
|
|
48
|
+
timeFormat: string;
|
|
49
|
+
timeStampFormat: string;
|
|
50
|
+
buildDate?: Date | undefined;
|
|
51
|
+
version?: string | undefined;
|
|
52
|
+
};
|
|
53
|
+
}>;
|
|
38
54
|
}
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { generateAuthorizationHeaders } from './generateAuthorizationHeaders.js';
|
|
5
5
|
import { FMError } from '../FMError.js';
|
|
6
|
-
import {
|
|
6
|
+
import { FMHostMetadata } from '../types.js';
|
|
7
|
+
import { ApiResults } from '../models/apiResults.js';
|
|
8
|
+
import z from 'zod';
|
|
9
|
+
import { DatabaseSessionPool } from './databaseSessionPool.js';
|
|
10
|
+
import { DatabaseConstantSession } from './databaseConstantSession.js';
|
|
7
11
|
/**
|
|
8
12
|
* Represents a FileMaker host.
|
|
9
|
-
* @implements {HostBase}
|
|
10
13
|
*/
|
|
11
14
|
export default class FMHost {
|
|
12
15
|
hostname;
|
|
@@ -35,12 +38,17 @@ export default class FMHost {
|
|
|
35
38
|
}
|
|
36
39
|
};
|
|
37
40
|
}
|
|
41
|
+
/** FileMaker host date format converted to Moment-compatible tokens. */
|
|
38
42
|
get dateFormat() {
|
|
39
43
|
return this.metadata.productInfo.dateFormat
|
|
40
44
|
.replace('dd', 'DD')
|
|
41
45
|
.replace('yyyy', 'YYYY');
|
|
42
46
|
}
|
|
43
|
-
|
|
47
|
+
/** FileMaker host time format. */
|
|
48
|
+
get timeFormat() {
|
|
49
|
+
return this.metadata.productInfo.timeFormat;
|
|
50
|
+
}
|
|
51
|
+
/** FileMaker host timestamp format converted to Moment-compatible tokens. */
|
|
44
52
|
get timeStampFormat() {
|
|
45
53
|
return this.metadata.productInfo.timeStampFormat
|
|
46
54
|
.replace('dd', 'DD')
|
|
@@ -62,9 +70,15 @@ export default class FMHost {
|
|
|
62
70
|
method: 'GET',
|
|
63
71
|
headers
|
|
64
72
|
});
|
|
65
|
-
const data =
|
|
73
|
+
const data = ApiResults.extend({
|
|
74
|
+
response: z.object({
|
|
75
|
+
databases: z.array(z.object({
|
|
76
|
+
name: z.string()
|
|
77
|
+
}))
|
|
78
|
+
}).optional()
|
|
79
|
+
}).parse(await _fetch.json());
|
|
66
80
|
// console.log(data.messages[0])
|
|
67
|
-
if (data.messages[0].code ===
|
|
81
|
+
if (data.messages[0].code === 0 && data.response) {
|
|
68
82
|
return data.response.databases;
|
|
69
83
|
}
|
|
70
84
|
else {
|
|
@@ -79,17 +93,23 @@ export default class FMHost {
|
|
|
79
93
|
* @return {Database<T>} A new Database instance.
|
|
80
94
|
*/
|
|
81
95
|
database(data) {
|
|
82
|
-
|
|
96
|
+
if (data.credentials.method === 'filemaker')
|
|
97
|
+
return new DatabaseSessionPool(this, data);
|
|
98
|
+
// @ts-expect-error types are correct here
|
|
99
|
+
return new DatabaseConstantSession(this, data);
|
|
83
100
|
}
|
|
101
|
+
/** Fetch and cache FileMaker host product metadata. */
|
|
84
102
|
async getMetadata() {
|
|
85
103
|
if (this._metadata)
|
|
86
104
|
return this._metadata;
|
|
87
105
|
const _fetch = await fetch(`${this.protocol}//${this.hostname}/fmi/data/v2/productInfo`, {
|
|
88
106
|
method: 'GET'
|
|
89
107
|
});
|
|
90
|
-
const
|
|
108
|
+
const raw = await _fetch.json();
|
|
109
|
+
console.log(raw);
|
|
110
|
+
const data = ApiResults.extend({ response: FMHostMetadata.optional() }).parse(raw);
|
|
91
111
|
// console.log(data.messages[0])
|
|
92
|
-
if (data.messages[0].code ===
|
|
112
|
+
if (data.messages[0].code === 0 && data.response) {
|
|
93
113
|
this._metadata = data.response;
|
|
94
114
|
return data.response;
|
|
95
115
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { type FMHostMetadata } from '../types.js';
|
|
2
2
|
import { type Moment } from 'moment';
|
|
3
|
+
import { type z } from 'zod';
|
|
4
|
+
import { type DatabaseProtocol } from './Session.js';
|
|
3
5
|
export interface HostBase {
|
|
4
6
|
readonly hostname: string;
|
|
5
|
-
readonly protocol:
|
|
7
|
+
readonly protocol: DatabaseProtocol;
|
|
6
8
|
readonly timezoneOffsetFunc: (moment: Moment) => number;
|
|
7
9
|
readonly verify: boolean;
|
|
8
|
-
metadata: FMHostMetadata
|
|
10
|
+
metadata: z.infer<typeof FMHostMetadata>;
|
|
9
11
|
getMetadata: () => PromiseLike<any>;
|
|
10
12
|
timeFormat: string;
|
|
11
13
|
dateFormat: string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { CookieJar } from './CookieJar.js';
|
|
2
|
+
export type DatabaseProtocol = 'http:' | 'https:';
|
|
3
|
+
export type DatabaseEndpoint = `${DatabaseProtocol}//${string}/fmi/data/v2/databases/${string}`;
|
|
4
|
+
interface SessionProps {
|
|
5
|
+
token: string;
|
|
6
|
+
/**
|
|
7
|
+
* A cookie jar that will be automatically cloned and used for this session
|
|
8
|
+
*/
|
|
9
|
+
baseCookieJar?: CookieJar;
|
|
10
|
+
/**
|
|
11
|
+
* The full database endpoint
|
|
12
|
+
*/
|
|
13
|
+
endpoint: DatabaseEndpoint;
|
|
14
|
+
/**
|
|
15
|
+
* Defines if a session should be kept alive, and how often to ping the database (in milliseconds)
|
|
16
|
+
*/
|
|
17
|
+
keepAlive?: number;
|
|
18
|
+
abortSignal: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
export type Session = ReturnType<typeof session>;
|
|
21
|
+
export declare function session(props: SessionProps): {
|
|
22
|
+
fetch: (url: string | URL, init?: RequestInit) => Promise<Response>;
|
|
23
|
+
logout(): Promise<void>;
|
|
24
|
+
validate: () => Promise<void>;
|
|
25
|
+
};
|
|
26
|
+
export declare class HttpError extends Error {
|
|
27
|
+
readonly res: Response;
|
|
28
|
+
readonly message: string;
|
|
29
|
+
static new(res: Response): Promise<HttpError>;
|
|
30
|
+
protected constructor(res: Response, message: string);
|
|
31
|
+
get status(): number;
|
|
32
|
+
get statusText(): string;
|
|
33
|
+
}
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// import fetchWithCookies, {CookieJar} from 'node-fetch-cookies'
|
|
2
|
+
import { CookieJar } from './CookieJar.js';
|
|
3
|
+
import { ApiResults } from '../models/apiResults.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
export function session(props) {
|
|
6
|
+
const cookieJar = props.baseCookieJar ? CookieJar.clone(props.baseCookieJar) : new CookieJar();
|
|
7
|
+
let keepAliveInterval;
|
|
8
|
+
const shouldRedirectWithGet = (status, method) => (status === 303 ||
|
|
9
|
+
((status === 301 || status === 302) && method === 'POST'));
|
|
10
|
+
const sessionFetch = async (url, init = {}) => {
|
|
11
|
+
let urlParsed = url instanceof URL ? url : new URL(url);
|
|
12
|
+
let requestInit = { ...init };
|
|
13
|
+
requestInit.signal = requestInit.signal ? AbortSignal.any([requestInit.signal, props.abortSignal]) : props.abortSignal;
|
|
14
|
+
for (let redirectCount = 0; redirectCount < 10; redirectCount++) {
|
|
15
|
+
/**
|
|
16
|
+
* Bind the authorization token to the request
|
|
17
|
+
*/
|
|
18
|
+
const headers = new Headers(requestInit.headers);
|
|
19
|
+
headers.set('Authorization', 'Bearer ' + props.token);
|
|
20
|
+
const cookieHeader = cookieJar.getCookieHeader(urlParsed);
|
|
21
|
+
if (cookieHeader)
|
|
22
|
+
headers.set('Cookie', cookieHeader);
|
|
23
|
+
else
|
|
24
|
+
headers.delete('Cookie');
|
|
25
|
+
const res = await fetch(urlParsed, {
|
|
26
|
+
...requestInit,
|
|
27
|
+
headers,
|
|
28
|
+
redirect: 'manual'
|
|
29
|
+
});
|
|
30
|
+
for (const header of res.headers.getSetCookie()) {
|
|
31
|
+
cookieJar.addCookie(urlParsed, header);
|
|
32
|
+
}
|
|
33
|
+
if (res.status >= 300 && res.status < 400) {
|
|
34
|
+
const location = res.headers.get('location');
|
|
35
|
+
if (!location) {
|
|
36
|
+
throw new Error(`Redirect response missing Location header for ${urlParsed.toString()}`);
|
|
37
|
+
}
|
|
38
|
+
urlParsed = new URL(location, urlParsed);
|
|
39
|
+
const method = (requestInit.method ?? 'GET').toUpperCase();
|
|
40
|
+
if (shouldRedirectWithGet(res.status, method)) {
|
|
41
|
+
requestInit = {
|
|
42
|
+
...requestInit,
|
|
43
|
+
method: 'GET',
|
|
44
|
+
body: undefined
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
throw await HttpError.new(res);
|
|
51
|
+
}
|
|
52
|
+
return res;
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`Too many redirects while fetching ${urlParsed.toString()}`);
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* sessionValidate throws an error if the session is invalid
|
|
58
|
+
*/
|
|
59
|
+
const sessionValidate = async () => {
|
|
60
|
+
const res = await sessionFetch(`${props.endpoint}/validateSession`);
|
|
61
|
+
ApiResults.extend({
|
|
62
|
+
isSessionInUse: z.boolean()
|
|
63
|
+
}).parse(await res.json());
|
|
64
|
+
};
|
|
65
|
+
if (props.keepAlive) {
|
|
66
|
+
keepAliveInterval = setInterval(() => {
|
|
67
|
+
void sessionValidate().catch(() => {
|
|
68
|
+
// Keep-alive failures should not create unhandled rejections.
|
|
69
|
+
});
|
|
70
|
+
}, props.keepAlive);
|
|
71
|
+
keepAliveInterval.unref?.();
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
fetch: sessionFetch,
|
|
75
|
+
async logout() {
|
|
76
|
+
if (keepAliveInterval)
|
|
77
|
+
clearInterval(keepAliveInterval);
|
|
78
|
+
await fetch(`${props.endpoint}/sessions/${props.token}`, {
|
|
79
|
+
method: 'DELETE'
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
validate: sessionValidate
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export class HttpError extends Error {
|
|
86
|
+
res;
|
|
87
|
+
message;
|
|
88
|
+
static async new(res) {
|
|
89
|
+
let message = `HTTP Error: [${res.status} ${res.statusText}]:`;
|
|
90
|
+
try {
|
|
91
|
+
message += await res.text();
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
message += ' (failed to read response body)';
|
|
95
|
+
}
|
|
96
|
+
return new this(res, message);
|
|
97
|
+
}
|
|
98
|
+
constructor(res, message) {
|
|
99
|
+
super();
|
|
100
|
+
this.res = res;
|
|
101
|
+
this.message = message;
|
|
102
|
+
}
|
|
103
|
+
get status() {
|
|
104
|
+
return this.res.status;
|
|
105
|
+
}
|
|
106
|
+
get statusText() {
|
|
107
|
+
return this.res.statusText;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -1,58 +1,59 @@
|
|
|
1
|
-
import { EventEmitter } from 'events';
|
|
2
1
|
import { type LayoutInterface } from '../layouts/layoutInterface.js';
|
|
3
2
|
import { Layout } from '../layouts/layout.js';
|
|
4
3
|
import { type databaseOptionsWithExternalSources, type Script } from '../types.js';
|
|
5
4
|
import { type HostBase } from './HostBase.js';
|
|
6
5
|
import { type DatabaseBase } from './databaseBase.js';
|
|
7
|
-
import { type ApiResults } from '../models/apiResults.js';
|
|
8
6
|
import { type DatabaseStructure } from '../databaseStructure.js';
|
|
9
|
-
import { type
|
|
7
|
+
import { type DatabaseEndpoint, type Session } from './Session.js';
|
|
8
|
+
import { z, type ZodType } from 'zod';
|
|
10
9
|
/**
|
|
11
10
|
* Represents a database connection.
|
|
12
11
|
* @template T - The structure of the database.
|
|
13
12
|
*/
|
|
14
|
-
export declare class Database<T extends DatabaseStructure>
|
|
13
|
+
export declare abstract class Database<T extends DatabaseStructure> implements DatabaseBase {
|
|
15
14
|
#private;
|
|
16
|
-
private _token;
|
|
17
15
|
readonly host: HostBase;
|
|
18
|
-
private readonly connection_details;
|
|
19
|
-
private cookies;
|
|
20
16
|
readonly name: string;
|
|
21
17
|
readonly debug: boolean;
|
|
22
|
-
|
|
23
|
-
private generateExternalSourceLogin;
|
|
18
|
+
protected canOpenNewConnections: boolean;
|
|
24
19
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* @returns {Promise<void>} A promise that resolves with no value once the logout is successful.
|
|
29
|
-
* @throws {Error} Throws an error if the user is not logged in.
|
|
20
|
+
* Used during events where the application must logout
|
|
21
|
+
* @private
|
|
30
22
|
*/
|
|
23
|
+
protected abortController: AbortController;
|
|
24
|
+
protected constructor(host: HostBase, conn: databaseOptionsWithExternalSources<unknown>);
|
|
25
|
+
login(): Promise<void>;
|
|
31
26
|
logout(): Promise<void>;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
32
29
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* @
|
|
36
|
-
* @throws {Error} - Throws an error if already logged in and forceLogin is false.
|
|
37
|
-
* @throws {FMError} - Throws an FMError if login fails.
|
|
38
|
-
* @return {Promise<string>} - Returns a promise that resolves to the access token upon successful login.
|
|
30
|
+
* The inheriting database class must implement this method to provide a session object.
|
|
31
|
+
* @param callback
|
|
32
|
+
* @protected
|
|
39
33
|
*/
|
|
40
|
-
|
|
41
|
-
get token(): string;
|
|
34
|
+
protected abstract withSession<T>(callback: (session: Session) => Promise<T>): Promise<T>;
|
|
42
35
|
/**
|
|
43
36
|
* Returns the endpoint URL for the database connection.
|
|
44
37
|
*
|
|
45
38
|
* @returns {string} The endpoint URL.
|
|
46
39
|
*/
|
|
47
|
-
get endpoint():
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
40
|
+
get endpoint(): DatabaseEndpoint;
|
|
41
|
+
/**
|
|
42
|
+
* Uses an available session to run a fetch
|
|
43
|
+
* @param url
|
|
44
|
+
* @param options
|
|
45
|
+
*/
|
|
46
|
+
fetch(url: string | URL, options?: RequestInit): Promise<Response>;
|
|
47
|
+
/**
|
|
48
|
+
* Uses an available session to run a fetch on a FileMaker Data API JSON endpoint. Also applies JSON/Zod type enforcement on result.
|
|
49
|
+
*/
|
|
50
|
+
fetchJSON<T extends ZodType | null = null>(url: string | URL, options: RequestInit & {
|
|
51
|
+
type: T;
|
|
52
|
+
}): Promise<T extends ZodType ? z.infer<T> & {
|
|
53
|
+
httpStatus: number;
|
|
54
|
+
} : {
|
|
55
|
+
httpStatus: number;
|
|
56
|
+
}>;
|
|
56
57
|
/**
|
|
57
58
|
* Retrieves a list of layouts in the current FileMaker database.
|
|
58
59
|
*
|
|
@@ -62,6 +63,8 @@ export declare class Database<T extends DatabaseStructure> extends EventEmitter
|
|
|
62
63
|
listLayouts(page?: number): Promise<Layout<LayoutInterface>[]>;
|
|
63
64
|
layout<R extends keyof T['layouts']>(name: R): Layout<T['layouts'][R]>;
|
|
64
65
|
layout<R extends LayoutInterface>(name: string): Layout<R>;
|
|
66
|
+
/** Clears any Layout objects previously returned by `database.layout(...)`. */
|
|
65
67
|
clearLayoutCache(): void;
|
|
68
|
+
/** Creates a script reference you can pass to read and write helpers. */
|
|
66
69
|
script(name: string, parameter?: string): Script;
|
|
67
70
|
}
|
|
@@ -1,106 +1,48 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Copyright (c) 2023-2024. See LICENSE file for more information
|
|
3
3
|
*/
|
|
4
|
-
import { EventEmitter } from 'events';
|
|
5
|
-
import { generateAuthorizationHeaders } from './generateAuthorizationHeaders.js';
|
|
6
4
|
import { FMError } from '../FMError.js';
|
|
7
5
|
import { Layout } from '../layouts/layout.js';
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
import
|
|
6
|
+
import { ApiLayout, ApiResults } from '../models/apiResults.js';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { addHeaders } from '../utils/addHeaders.js';
|
|
9
|
+
import process from 'node:process';
|
|
11
10
|
/**
|
|
12
11
|
* Represents a database connection.
|
|
13
12
|
* @template T - The structure of the database.
|
|
14
13
|
*/
|
|
15
|
-
export class Database
|
|
16
|
-
_token = '';
|
|
14
|
+
export class Database {
|
|
17
15
|
host;
|
|
18
|
-
connection_details;
|
|
19
|
-
cookies = new CookieJar();
|
|
20
16
|
name;
|
|
21
17
|
debug;
|
|
22
18
|
#layoutCache = new Map();
|
|
19
|
+
canOpenNewConnections = true;
|
|
20
|
+
/**
|
|
21
|
+
* Used during events where the application must logout
|
|
22
|
+
* @private
|
|
23
|
+
*/
|
|
24
|
+
abortController = new AbortController();
|
|
23
25
|
constructor(host, conn) {
|
|
24
|
-
super();
|
|
25
26
|
this.host = host;
|
|
26
27
|
this.name = conn.database;
|
|
27
|
-
this.connection_details = conn;
|
|
28
28
|
this.debug = conn.debug ?? false;
|
|
29
|
+
process.on('SIGINT', () => { void this.close(); });
|
|
30
|
+
process.on('SIGTERM', () => { void this.close(); });
|
|
31
|
+
process.on('beforeExit', () => { void this.close(); });
|
|
29
32
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (data.credentials.method === 'filemaker') {
|
|
33
|
-
const _data = data.credentials;
|
|
34
|
-
return {
|
|
35
|
-
database: data.database,
|
|
36
|
-
username: _data.username,
|
|
37
|
-
password: _data.password
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
throw new Error('Not yet supported login method');
|
|
42
|
-
}
|
|
33
|
+
async login() {
|
|
34
|
+
await Promise.resolve();
|
|
43
35
|
}
|
|
44
|
-
/**
|
|
45
|
-
* Logs out the user by deleting the current session token.
|
|
46
|
-
* Throws an error if the user is not logged in.
|
|
47
|
-
*
|
|
48
|
-
* @returns {Promise<void>} A promise that resolves with no value once the logout is successful.
|
|
49
|
-
* @throws {Error} Throws an error if the user is not logged in.
|
|
50
|
-
*/
|
|
51
36
|
async logout() {
|
|
52
|
-
|
|
53
|
-
throw new Error('Not logged in');
|
|
54
|
-
const _fetch = await fetch(`${this.endpoint}/sessions/${this.token}`, {
|
|
55
|
-
method: 'DELETE',
|
|
56
|
-
headers: {
|
|
57
|
-
'content-type': 'application/json'
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
await _fetch.json();
|
|
61
|
-
this._token = '';
|
|
37
|
+
await this.close();
|
|
62
38
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
* @throws {Error} - Throws an error if already logged in and forceLogin is false.
|
|
68
|
-
* @throws {FMError} - Throws an FMError if login fails.
|
|
69
|
-
* @return {Promise<string>} - Returns a promise that resolves to the access token upon successful login.
|
|
70
|
-
*/
|
|
71
|
-
async login(forceLogin = false) {
|
|
72
|
-
if (this.token !== '' && !forceLogin)
|
|
73
|
-
return;
|
|
74
|
-
// Reset cookies
|
|
75
|
-
this.cookies = new CookieJar();
|
|
76
|
-
await this.host.getMetadata();
|
|
77
|
-
if (this.connection_details.credentials.method === 'token') {
|
|
78
|
-
this._token = (this.connection_details.credentials).token;
|
|
79
|
-
return this.token;
|
|
80
|
-
}
|
|
81
|
-
const url = new URL(`${this.endpoint}/sessions`);
|
|
82
|
-
url.hostname = this.host.hostname;
|
|
83
|
-
const res = await fetch(url, {
|
|
84
|
-
method: 'POST',
|
|
85
|
-
headers: generateAuthorizationHeaders(this.connection_details.credentials),
|
|
86
|
-
body: JSON.stringify({
|
|
87
|
-
fmDataSource: this.connection_details.externalSources.map(i => {
|
|
88
|
-
const _i = i;
|
|
89
|
-
return this.generateExternalSourceLogin(_i);
|
|
90
|
-
})
|
|
91
|
-
})
|
|
92
|
-
});
|
|
93
|
-
const _res = (await res.json());
|
|
94
|
-
if (res.status === 200) {
|
|
95
|
-
this._token = res.headers.get('x-fm-data-access-token') ?? '';
|
|
96
|
-
return this._token;
|
|
97
|
-
}
|
|
98
|
-
else {
|
|
99
|
-
throw new FMError(_res.messages[0].code, _res.status, res);
|
|
100
|
-
}
|
|
39
|
+
async close() {
|
|
40
|
+
this.canOpenNewConnections = false;
|
|
41
|
+
if (!this.abortController.signal.aborted)
|
|
42
|
+
this.abortController.abort('Closing connection');
|
|
101
43
|
}
|
|
102
|
-
|
|
103
|
-
|
|
44
|
+
async [Symbol.asyncDispose]() {
|
|
45
|
+
await this.close();
|
|
104
46
|
}
|
|
105
47
|
/**
|
|
106
48
|
* Returns the endpoint URL for the database connection.
|
|
@@ -110,57 +52,49 @@ export class Database extends EventEmitter {
|
|
|
110
52
|
get endpoint() {
|
|
111
53
|
return `${this.host.protocol}//${this.host.hostname}/fmi/data/v2/databases/${this.name}`;
|
|
112
54
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
: typeof url === 'string' ? url : url.url}`);
|
|
55
|
+
/**
|
|
56
|
+
* Uses an available session to run a fetch
|
|
57
|
+
* @param url
|
|
58
|
+
* @param options
|
|
59
|
+
*/
|
|
60
|
+
async fetch(url, options) {
|
|
61
|
+
return await this.withSession(async (session) => await session.fetch(url, options));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Uses an available session to run a fetch on a FileMaker Data API JSON endpoint. Also applies JSON/Zod type enforcement on result.
|
|
65
|
+
*/
|
|
66
|
+
async fetchJSON(url, options) {
|
|
67
|
+
const _options = options ?? {};
|
|
68
|
+
addHeaders(_options, {
|
|
69
|
+
'Content-Type': 'application/json'
|
|
70
|
+
});
|
|
71
|
+
const res = await this.fetch(url, _options);
|
|
72
|
+
const rawData = await res.json();
|
|
73
|
+
if (this.debug)
|
|
74
|
+
console.log(rawData.response);
|
|
75
|
+
if (options.type !== null) {
|
|
76
|
+
const data = ApiResults.extend({ response: options.type.optional() })
|
|
77
|
+
.parse(rawData);
|
|
78
|
+
if (data.messages[0].code !== 0) {
|
|
79
|
+
throw new FMError(data.messages[0].code, res.status, data);
|
|
139
80
|
}
|
|
140
|
-
return
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
81
|
+
return data.response
|
|
82
|
+
// @ts-expect-error is correct
|
|
83
|
+
? {
|
|
84
|
+
...data.response,
|
|
85
|
+
httpStatus: res.status
|
|
86
|
+
}
|
|
87
|
+
// @ts-expect-error is correct
|
|
88
|
+
: { httpStatus: res.status };
|
|
145
89
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
async _apiRequestJSON(url, options = {}) {
|
|
150
|
-
if (!options.headers)
|
|
151
|
-
options.headers = {};
|
|
152
|
-
options.headers['content-type'] = options.headers['content-type'] ? options.headers['content-type'] : 'application/json';
|
|
153
|
-
const _fetch = await this._apiRequestRaw(url, options);
|
|
154
|
-
const data = await _fetch.json();
|
|
155
|
-
// Remove response if it is empty. This makes checking for an empty response easier
|
|
156
|
-
if (data.response && Object.keys(data.response).length === 0)
|
|
157
|
-
delete data.response;
|
|
158
|
-
// console.log(data.messages[0])
|
|
159
|
-
if (data.messages[0].code !== '0') {
|
|
160
|
-
throw new FMError(data.messages[0].code, _fetch.status, data);
|
|
90
|
+
const data = ApiResults.parse(rawData);
|
|
91
|
+
if (data.messages[0].code !== 0) {
|
|
92
|
+
throw new FMError(data.messages[0].code, res.status, data);
|
|
161
93
|
}
|
|
162
|
-
|
|
163
|
-
return
|
|
94
|
+
// @ts-expect-error is correct
|
|
95
|
+
return {
|
|
96
|
+
httpStatus: res.status
|
|
97
|
+
};
|
|
164
98
|
}
|
|
165
99
|
/**
|
|
166
100
|
* Retrieves a list of layouts in the current FileMaker database.
|
|
@@ -169,9 +103,9 @@ export class Database extends EventEmitter {
|
|
|
169
103
|
* @throws {FMError} If there was an error retrieving the layouts.
|
|
170
104
|
*/
|
|
171
105
|
async listLayouts(page = 0) {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
106
|
+
const res = await this.fetchJSON(`${this.endpoint}/layouts?page=${encodeURIComponent(page)}`, {
|
|
107
|
+
type: z.object({ layouts: z.array(ApiLayout) })
|
|
108
|
+
});
|
|
175
109
|
const cycleLayoutNames = (layouts) => {
|
|
176
110
|
let names = [];
|
|
177
111
|
for (const layout of layouts) {
|
|
@@ -182,8 +116,11 @@ export class Database extends EventEmitter {
|
|
|
182
116
|
}
|
|
183
117
|
return names;
|
|
184
118
|
};
|
|
185
|
-
return cycleLayoutNames(
|
|
119
|
+
return cycleLayoutNames(res.layouts).map(layout => new Layout(this, layout));
|
|
186
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Returns a FileMaker Layout object by name.
|
|
123
|
+
*/
|
|
187
124
|
layout(name) {
|
|
188
125
|
let layout = this.#layoutCache.get(name);
|
|
189
126
|
if (layout)
|
|
@@ -192,9 +129,11 @@ export class Database extends EventEmitter {
|
|
|
192
129
|
this.#layoutCache.set(name, layout);
|
|
193
130
|
return layout;
|
|
194
131
|
}
|
|
132
|
+
/** Clears any Layout objects previously returned by `database.layout(...)`. */
|
|
195
133
|
clearLayoutCache() {
|
|
196
134
|
this.#layoutCache.clear();
|
|
197
135
|
}
|
|
136
|
+
/** Creates a script reference you can pass to read and write helpers. */
|
|
198
137
|
script(name, parameter = '') {
|
|
199
138
|
return { name, parameter };
|
|
200
139
|
}
|