@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.
Files changed (43) hide show
  1. package/README.md +60 -197
  2. package/dist/bin/generateTypes.js +1 -1
  3. package/dist/bin/stressSearch.d.ts +2 -0
  4. package/dist/bin/stressSearch.js +243 -0
  5. package/dist/connection/CookieJar.d.ts +19 -0
  6. package/dist/connection/CookieJar.js +99 -0
  7. package/dist/connection/FMHost.d.ts +24 -8
  8. package/dist/connection/FMHost.js +28 -8
  9. package/dist/connection/HostBase.d.ts +4 -2
  10. package/dist/connection/Session.d.ts +34 -0
  11. package/dist/connection/Session.js +109 -0
  12. package/dist/connection/database.d.ts +34 -31
  13. package/dist/connection/database.js +72 -133
  14. package/dist/connection/databaseBase.d.ts +22 -10
  15. package/dist/connection/databaseConstantSession.d.ts +15 -0
  16. package/dist/connection/databaseConstantSession.js +70 -0
  17. package/dist/connection/databaseSessionPool.d.ts +14 -0
  18. package/dist/connection/databaseSessionPool.js +148 -0
  19. package/dist/index.d.ts +18 -1
  20. package/dist/index.js +12 -0
  21. package/dist/layouts/layout.d.ts +5 -3
  22. package/dist/layouts/layout.js +11 -15
  23. package/dist/layouts/layoutBase.d.ts +3 -2
  24. package/dist/layouts/layoutRecordManager.d.ts +6 -15
  25. package/dist/layouts/layoutRecordManager.js +6 -15
  26. package/dist/models/apiResults.d.ts +208 -93
  27. package/dist/models/apiResults.js +95 -25
  28. package/dist/records/field.d.ts +17 -2
  29. package/dist/records/field.js +161 -237
  30. package/dist/records/getOperations/recordGetOperation.d.ts +7 -4
  31. package/dist/records/getOperations/recordGetOperation.js +15 -18
  32. package/dist/records/layoutRecord.d.ts +24 -7
  33. package/dist/records/layoutRecord.js +68 -73
  34. package/dist/records/portal.d.ts +0 -4
  35. package/dist/records/portal.js +0 -4
  36. package/dist/records/recordBase.d.ts +3 -2
  37. package/dist/types.d.ts +41 -17
  38. package/dist/types.js +12 -0
  39. package/dist/utils/addHeaders.d.ts +5 -0
  40. package/dist/utils/addHeaders.js +24 -0
  41. package/dist/utils/query.d.ts +7 -0
  42. package/dist/utils/query.js +5 -0
  43. 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, type FMHostMetadata, type loginOptionsClaris, type loginOptionsFileMaker, type loginOptionsOAuth } from '../types.js';
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: 'http:' | 'https:';
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<any>;
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
- getMetadata(): Promise<FMHostMetadata>;
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 { Database } from './database.js';
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
- get timeFormat() { return this.metadata.productInfo.timeFormat; }
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 = await _fetch.json();
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 === '0' && data.response) {
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
- return new Database(this, data);
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 data = await _fetch.json();
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 === '0' && data.response) {
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: string;
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 RequestInfo, type RequestInit, type Response } from 'node-fetch';
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> extends EventEmitter implements DatabaseBase {
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
- constructor(host: HostBase, conn: databaseOptionsWithExternalSources);
23
- private generateExternalSourceLogin;
18
+ protected canOpenNewConnections: boolean;
24
19
  /**
25
- * Logs out the user by deleting the current session token.
26
- * Throws an error if the user is not logged in.
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
- * Logs in to the database. Not required, as this is often done automatically
34
- *
35
- * @param {boolean} [forceLogin=false] - Whether to force login even if already logged in.
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
- login(forceLogin?: boolean): Promise<string | undefined>;
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(): string;
48
- _apiRequestRaw(url: URL | RequestInfo, options?: RequestInit & {
49
- headers?: Record<string, string>;
50
- useCookieJar?: boolean;
51
- retries?: number;
52
- }, autoRelogin?: boolean): Promise<Response>;
53
- _apiRequestJSON<T = any>(url: URL | RequestInfo, options?: RequestInit & {
54
- headers?: Record<string, string>;
55
- }): Promise<ApiResults<T>>;
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 fetch from 'node-fetch';
9
- // @ts-expect-error - fetchWithCookies does not have available typescript types
10
- import fetchWithCookies, { CookieJar } from 'node-fetch-cookies';
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 extends EventEmitter {
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
- // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
31
- generateExternalSourceLogin(data) {
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
- if (this.token === '')
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
- * Logs in to the database. Not required, as this is often done automatically
65
- *
66
- * @param {boolean} [forceLogin=false] - Whether to force login even if already logged in.
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
- get token() {
103
- return this._token;
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
- async _apiRequestRaw(url, options = {}, autoRelogin = true) {
114
- if (this.debug) {
115
- console.log(`EASYFM DEBUG: ${JSON.stringify(options)} ${url instanceof URL
116
- ? url.toString()
117
- : typeof url === 'string' ? url : url.url}`);
118
- }
119
- const urlParsed = (url instanceof URL
120
- ? url
121
- : typeof url === 'string'
122
- ? new URL(url)
123
- : new URL(url.url));
124
- const reqIsToDBHost = urlParsed.hostname === this.host.hostname && urlParsed.pathname.startsWith("/fmi/data");
125
- if (reqIsToDBHost && this.token === '')
126
- await this.login(true);
127
- if (!options.headers)
128
- options.headers = {};
129
- if (reqIsToDBHost)
130
- options.headers.authorization = 'Bearer ' + this._token;
131
- const _fetch = options.useCookieJar
132
- ? await fetchWithCookies(this.cookies, url, options)
133
- : await fetch(url, options);
134
- if (!_fetch.ok && (!options.retries || options.retries > 0)) {
135
- if (this.debug) {
136
- console.log(`EASYFM DEBUG: RE-ATTEMPTING REQUEST (${_fetch.status}) ${url instanceof URL
137
- ? url.toString()
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 await this._apiRequestRaw(url, { ...options, retries: (options?.retries ?? 1) - 1 });
141
- }
142
- else if (_fetch.status === 401 && reqIsToDBHost && autoRelogin) {
143
- await this.login(true);
144
- return await this._apiRequestRaw(url, options, false);
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
- else
147
- return _fetch;
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
- data.httpStatus = _fetch.status;
163
- return data;
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 req = await this._apiRequestJSON(`${this.endpoint}/layouts?page=${encodeURIComponent(page)}`);
173
- if (!req.response)
174
- throw new FMError(req.messages[0].code, req.httpStatus, req.messages[0].message);
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(req.response.layouts).map(layout => new Layout(this, layout));
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
  }