@jd-data-limited/easy-fm 4.1.15 → 5.0.0-beta.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.
Files changed (58) hide show
  1. package/README.md +60 -196
  2. package/dist/bin/generateTypes.js +3 -3
  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 +25 -11
  8. package/dist/connection/FMHost.js +29 -11
  9. package/dist/connection/HostBase.d.ts +4 -4
  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 +24 -4
  20. package/dist/index.js +15 -1
  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/layoutInterface.d.ts +2 -1
  25. package/dist/layouts/layoutRecordManager.d.ts +6 -15
  26. package/dist/layouts/layoutRecordManager.js +6 -15
  27. package/dist/models/apiResults.d.ts +209 -93
  28. package/dist/models/apiResults.js +95 -25
  29. package/dist/records/field.d.ts +17 -2
  30. package/dist/records/field.js +161 -237
  31. package/dist/records/fields/baseField.d.ts +57 -0
  32. package/dist/records/fields/baseField.js +59 -0
  33. package/dist/records/fields/containerField.d.ts +35 -0
  34. package/dist/records/fields/containerField.js +78 -0
  35. package/dist/records/fields/field.d.ts +3 -0
  36. package/dist/records/fields/field.js +1 -0
  37. package/dist/records/fields/valueField.d.ts +41 -0
  38. package/dist/records/fields/valueField.js +108 -0
  39. package/dist/records/getOperations/recordGetOperation.d.ts +7 -4
  40. package/dist/records/getOperations/recordGetOperation.js +22 -26
  41. package/dist/records/layoutRecord.d.ts +27 -9
  42. package/dist/records/layoutRecord.js +84 -97
  43. package/dist/records/portal.d.ts +1 -5
  44. package/dist/records/portal.js +0 -4
  45. package/dist/records/portalBase.d.ts +1 -2
  46. package/dist/records/portalRecord.d.ts +7 -4
  47. package/dist/records/portalRecord.js +12 -0
  48. package/dist/records/recordBase.d.ts +8 -6
  49. package/dist/records/recordBase.js +32 -33
  50. package/dist/types.d.ts +42 -18
  51. package/dist/types.js +12 -0
  52. package/dist/utils/addHeaders.d.ts +5 -0
  53. package/dist/utils/addHeaders.js +24 -0
  54. package/dist/utils/query.d.ts +24 -8
  55. package/dist/utils/query.js +60 -20
  56. package/dist/utils/temporal.d.ts +9 -0
  57. package/dist/utils/temporal.js +110 -0
  58. package/package.json +35 -31
package/README.md CHANGED
@@ -1,229 +1,93 @@
1
- # Introduction
2
-
3
- A FileMaker Data API client for NodeJS
4
-
5
- easy-fm is a Node.js module that allows you to interact with
6
- a FileMaker database stored on a FileMaker server
7
- or FileMaker Cloud. This module interacts with your server using the
8
- FileMaker Data API.
9
-
10
- # Contents
11
-
12
- <!-- TOC -->
13
- * [Introduction](#introduction)
14
- * [Contents](#contents)
15
- * [Installation](#installation)
16
- * [Usage](#usage)
17
- * [Connecting to a database](#connecting-to-a-database)
18
- * [An important note about timezones](#an-important-note-about-timezones)
19
- * [Getting records](#getting-records)
20
- * [Fetch a range of records](#fetch-a-range-of-records)
21
- * [Searching for records](#searching-for-records)
22
- * [Fetch a record using its record ID (NOT RECOMMENDED)](#fetch-a-record-using-its-record-id-not-recommended)
23
- * [Create a record](#create-a-record)
24
- * [Modify a record](#modify-a-record)
25
- * [Field names](#field-names)
26
- * [Portal names](#portal-names)
27
- * [Typescript Implementation](#typescript-implementation)
28
- <!-- TOC -->
29
-
30
- # Installation
31
-
32
- ```npm
33
- npm install @jd-data-limited/easy-fm --save
34
- ```
1
+ # easy-fm
35
2
 
36
- easy-fm also requires the following to be configured within your FileMaker enviroment:
3
+ `easy-fm` is a Node.js client for Claris FileMaker Data API.
37
4
 
38
- 1. Enable the FileMaker Data API from the server's admin console. This setting is located
39
- in `Connectors > FileMaker Data API`.
40
- 2. Create a FileMaker database account for easy-fm to use. This account must have the 'Access via FileMaker Data API (
41
- fmrest)' extended privilege
5
+ It helps you connect Node.js code to FileMaker Server or FileMaker Cloud so you can fetch records, run finds, edit data, work with portals, and run scripts using a typed JavaScript or TypeScript API.
42
6
 
43
- ---
7
+ ## Requirements
44
8
 
45
- # Usage
9
+ - Node.js `22+`
10
+ - FileMaker Data API enabled
11
+ - account with `fmrest` privilege
12
+ - at least one accessible layout
46
13
 
47
- ## Connecting to a database
14
+ Claris Data API reference:
15
+ [`help.claris.com/en/data-api-guide/content/index.html`](https://help.claris.com/en/data-api-guide/content/index.html)
48
16
 
49
- ```javascript
50
- import FMHost from "easy-fm"; // Import the module
51
- const host = new FMHost("https://<your-servers-address>")
52
- const database = host.database({
53
- database: "your_database.fmp12",
54
- credentials: {
55
- method: "filemaker",
56
- username: "<username>",
57
- password: "<password>"
58
- },
59
- externalSources: []
60
- })
17
+ ## Install
61
18
 
62
- // OPTIONAL - EasyFM will automatically attempt a login anyway when you perform your first operation
63
- database.login().then(() => {
64
-
65
- })
19
+ ```sh
20
+ npm install @jd-data-limited/easy-fm
66
21
  ```
67
22
 
68
- > **NOTE:** A connection will only give you access to the layouts in the database you are connected to, and not the
69
- > layouts
70
- > in
71
- > any external sources that you have specified.
72
- >
73
- > If you need to interact with layouts on multiple databases, you need to open a separate connection for each.
23
+ ## Quick Start
74
24
 
75
- ## An important note about timezones
25
+ ```ts
26
+ import {FMHost} from "@jd-data-limited/easy-fm"
76
27
 
77
- Although it is recommended, timestamps in FileMaker databases are not always stored in UTC time. To account for this,
78
- EasyFM allows you to specify a function/method that determines the server's current timezone.
79
- EasyFM will use this timezone offset to convert timestamps to and from JavaScript Date objects.
80
-
81
- ```typescript
82
- import FMHost from "easy-fm";
83
- import {type Moment} from 'moment'
84
-
85
- const host = new FMHost("https://<your-servers-address>", (moment: Moment) => {
28
+ const host = new FMHost("https://example.com")
86
29
 
30
+ const database = host.database({
31
+ database: "Contacts",
32
+ credentials: {
33
+ method: "filemaker",
34
+ username: "api-user",
35
+ password: "secret"
36
+ },
37
+ externalSources: []
87
38
  })
88
- ```
89
-
90
- ## Getting records
91
39
 
92
- One of (if not the) most common interactions you'll need to use is fetching records.
40
+ const layout = database.layout("Contacts_API")
41
+ const records = await layout.records.list({
42
+ portals: {},
43
+ limit: 25
44
+ }).fetch()
93
45
 
94
- ### Fetch a range of records
95
-
96
- ```javascript
97
- let layout = database.getLayout("Your layout name")
98
- let query = layout.records.list({
99
- portals: {
100
- test: {limit: 10, offset: 1} // Include results from the 'test' portal
101
- },
102
- limit: 10, // Limit result set to 10 records...
103
- offset: 30 // ...starting from the 30th record
104
- })
105
-
106
- let records = await query.fetch()
107
- console.log(records)
46
+ for (const record of records) {
47
+ console.log(record.fields.FirstName.value, record.fields.LastName.value)
48
+ }
108
49
  ```
109
50
 
110
- ### Searching for records
51
+ ## Important Notes
111
52
 
112
- Searching for records uses the same syntax as above, but with additional steps to add your search parameters.
53
+ - FileMaker Data API is layout-based, so fields and portals must be available on the layout you use.
54
+ - Portal data is not fetched unless you request it.
55
+ - `recordId` and `modId` are internal FileMaker IDs. Avoid treating `recordId` as a business ID; `modId` can still be useful for change or concurrency checks.
56
+ - Date and timestamp formatting follows the host timezone rules you configure, with defaults chosen to better match FileMaker UI expectations.
113
57
 
114
- ```javascript
115
- let layout = database.getLayout("Your layout name")
116
- let query = layout.records.list({
117
- portals: {
118
- test: {limit: 10, offset: 1} // Include results from the 'test' portal
119
- },
120
- limit: 10, // Limit result set to 10 records...
121
- offset: 30 // ...starting from the 30th record
122
- })
58
+ ## Documentation
123
59
 
124
- query.addRequest({"GroupID": "=abc"}) // Add a filter
60
+ > Updating from v4 to v5? Check the guide here; [`docs/upgrade-to-v5.md`](./docs/upgrade-to-v5.md)
125
61
 
126
- let records = await query.fetch()
127
- console.log(records)
128
- ```
62
+ Start here:
129
63
 
130
- ### Fetch a record using its record ID (NOT RECOMMENDED)
64
+ - [`docs/getting-started.md`](./docs/getting-started.md): first connection, first find, closing sessions
65
+ - [`docs/core-concepts.md`](./docs/core-concepts.md): host, database, layout, record, portal, IDs
131
66
 
132
- > Please note: When in FileMaker Pro, a record's ID is returned when using Get(RecordID). If you need to fetch a record
133
- > using a different ID, use the search method above.
67
+ Task guides:
134
68
 
135
- ```javascript
136
- let layout = database.getLayout("Your layout name")
137
- let record = await layout.records.get(164)
138
- console.log(record)
139
- ```
69
+ - [`docs/working-with-records.md`](./docs/working-with-records.md): list, create, update, duplicate, delete, portal rows
70
+ - [`docs/query-recipes.md`](./docs/query-recipes.md): find requests, sorting, paging, script hooks
71
+ - [`docs/authentication-and-sessions.md`](./docs/authentication-and-sessions.md): auth modes, pooling, lifecycle
72
+ - [`docs/typescript-layouts.md`](./docs/typescript-layouts.md): typed layouts and stronger autocomplete
73
+ - [`docs/testGuide.md`](./docs/testGuide.md): local test commands
140
74
 
141
- ### Create a record
75
+ API reference:
142
76
 
143
- ```javascript
144
- let layout = database.getLayout("Your layout name")
145
- let record = await layout.records.create()
77
+ - [`docs_out/index.html`](./docs_out/index.html): generated TypeDoc site
146
78
 
147
- record.fields["Field1"].value = "Value here"
148
- record.fields["Field2"].value = "Value here"
149
- record.fields["Field3"].value = "Value here"
79
+ ## Build Docs
150
80
 
151
- await record.commit()
81
+ ```sh
82
+ npm run docs:build
152
83
  ```
153
84
 
154
- ### Modify a record
155
-
156
- ```javascript
157
- let layout = database.getLayout("Your layout name")
158
- let record = await layout.records.get(164)
159
-
160
- record.fields["Field1"].value = "Value here"
161
- record.fields["Field2"].value = "Value here"
162
- record.fields["Field3"].value = "Value here"
85
+ ## Test
163
86
 
164
- await record.commit()
87
+ ```sh
88
+ npm test
89
+ npm run test:unit
90
+ npm run test:integration
165
91
  ```
166
92
 
167
- # Field names
168
-
169
- When interacting with FileMaker, it is important to remember how FileMaker field names work.
170
-
171
- | Field name format | Use when.... |
172
- |-------------------------------|-----------------------------------------------------------------------------------------------------|
173
- | `FieldName` | Use this when the field you are accessing is in the same table that the layout has been assigned to |
174
- | `RelatedTableName::FieldName` | Use this when the field **is not** in the same table that the layout has been assigned to |
175
-
176
- > **NOTE:** You will not be able to access any fields that are not on the layout.
177
-
178
- # Portal names
179
-
180
- > Please read this section carefully if you are working with portals
181
-
182
- It is important to note that a portal's name **is not** the same as the name of the table that it links to. The name of
183
- a
184
- portal matches the object name it was assigned in FileMaker's layout editor.
185
-
186
- > **NOTE**: When no name has been manually assigned to it, it will default to the name of the related table.
187
-
188
- # Typescript Implementation
189
-
190
- `easy-fm` supports the use of TypeScript. Here's an example of how this works with `easy-fm`:
191
-
192
- ```typescript
193
- import FMHost, {Portal, Field, Container} from "@jd-data-limited/easy-fm";
194
-
195
- interface UsersLayout {
196
- fields: {
197
- // Map each field on the layout to a field type.
198
- first_name: Field<string>
199
- age: Field<number>
200
- birthdate: Field<Date>
201
- profile_picture: Field<Container>
202
- "MyRelatedTable::MyRelatedField": Field<string>
203
- },
204
- portals: {
205
- Files: {
206
- "Files::Field1": Field<string>
207
- }
208
- }
209
- }
210
-
211
- interface DatabaseStructure {
212
- layouts: {
213
- users: UsersLayout
214
- }
215
- }
216
-
217
- const host = new FMHost("https://example_filemaker_server.com")
218
- const database = host.database<DatabaseStructure>({
219
- database: "ExampleDatabase.fmp12",
220
- credentials: {method: "filemaker", username: "test", passsword: "test"},
221
- externalSources: []
222
- })
223
- await database.login()
224
-
225
- const layout = database.getLayout("users") // The UsersLayout interface will be automatically applied to all records within this layout
226
- const record = await layout.records.create()
227
- record.fields["first_name"].value = "Joe"
228
- record.fields["age"].value = 38
229
- ```
93
+ Integration tests require a working FileMaker environment and credentials.
@@ -78,7 +78,7 @@ export async function generateTypesCLI() {
78
78
  default: true,
79
79
  message: "Verify your server's identity",
80
80
  when(previousAnswers) {
81
- return previousAnswers.hostname.startsWith('https://');
81
+ return previousAnswers.hostname?.startsWith('https://');
82
82
  }
83
83
  },
84
84
  {
@@ -98,7 +98,7 @@ export async function generateTypesCLI() {
98
98
  mask: '*'
99
99
  }
100
100
  ]);
101
- const HOST = new FMHost(data.hostname, () => data.timezoneOffset, data.verify);
101
+ const HOST = new FMHost(data.hostname, data.verify);
102
102
  const DATABASE = HOST.database({
103
103
  database: data.database,
104
104
  credentials: {
@@ -108,7 +108,7 @@ export async function generateTypesCLI() {
108
108
  },
109
109
  externalSources: []
110
110
  });
111
- await DATABASE.login();
111
+ // await DATABASE.login()
112
112
  // Create file write stream
113
113
  const stream = fs.createWriteStream('./types.ts');
114
114
  const layouts = await DATABASE.listLayouts();
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Copyright (c) 2024. See LICENSE file for more information
4
+ */
5
+ import { Command, Option } from 'commander';
6
+ import { config } from 'dotenv';
7
+ import FMHost, { query } from '../index.js';
8
+ config();
9
+ function parseIntegerList(value) {
10
+ return value
11
+ .split(',')
12
+ .map(item => parseInt(item.trim(), 10))
13
+ .filter(item => Number.isFinite(item) && item > 0);
14
+ }
15
+ function percentile(sorted, ratio) {
16
+ if (sorted.length === 0)
17
+ return 0;
18
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1));
19
+ return sorted[index];
20
+ }
21
+ function summarizeDurations(durations, errors, wallClockMs) {
22
+ const sorted = [...durations].sort((a, b) => a - b);
23
+ const totalMs = sorted.reduce((sum, item) => sum + item, 0);
24
+ return {
25
+ count: sorted.length,
26
+ errors,
27
+ totalMs,
28
+ minMs: sorted[0] ?? 0,
29
+ maxMs: sorted[sorted.length - 1] ?? 0,
30
+ p50Ms: percentile(sorted, 0.5),
31
+ p95Ms: percentile(sorted, 0.95),
32
+ opsPerSecond: wallClockMs === 0 ? 0 : (sorted.length / wallClockMs) * 1000
33
+ };
34
+ }
35
+ function formatInteger(value) {
36
+ return new Intl.NumberFormat('en-US').format(value);
37
+ }
38
+ function formatDuration(value) {
39
+ return `${value.toFixed(1)} ms`;
40
+ }
41
+ function formatRate(value) {
42
+ return `${value.toFixed(2)} ops/s`;
43
+ }
44
+ function padCell(value, width, align = 'right') {
45
+ return align === 'left' ? value.padEnd(width) : value.padStart(width);
46
+ }
47
+ function buildTable(rows) {
48
+ const headers = ['conc', 'ops', 'errs', 'min', 'p50', 'p95', 'max', 'throughput'];
49
+ const body = rows.map(({ concurrency, stats }) => [
50
+ formatInteger(concurrency),
51
+ formatInteger(stats.count),
52
+ formatInteger(stats.errors),
53
+ formatDuration(stats.minMs),
54
+ formatDuration(stats.p50Ms),
55
+ formatDuration(stats.p95Ms),
56
+ formatDuration(stats.maxMs),
57
+ formatRate(stats.opsPerSecond)
58
+ ]);
59
+ const widths = headers.map((header, index) => Math.max(header.length, ...body.map(row => row[index].length)));
60
+ const headerRow = headers
61
+ .map((header, index) => padCell(header, widths[index], 'left'))
62
+ .join(' ');
63
+ const separatorRow = widths
64
+ .map(width => '-'.repeat(width))
65
+ .join(' ');
66
+ const bodyRows = body.map(row => row
67
+ .map((cell, index) => padCell(cell, widths[index]))
68
+ .join(' '));
69
+ return [headerRow, separatorRow, ...bodyRows].join('\n');
70
+ }
71
+ function printRunSummary(options) {
72
+ console.log('Stress Search');
73
+ console.log(` host: ${options.host}`);
74
+ console.log(` database: ${options.database}`);
75
+ console.log(` layout: ${options.layout}`);
76
+ console.log(` mode: ${options.mode}`);
77
+ if (options.mode === 'find')
78
+ console.log(` search: ${options.field} ${options.operator} ${options.value}`);
79
+ console.log(` limit: ${formatInteger(options.limit)}`);
80
+ console.log(` iterations/worker: ${formatInteger(options.iterations)}`);
81
+ console.log(` warmup/worker: ${formatInteger(options.warmup)}`);
82
+ console.log(` concurrency sweep: ${options.concurrencyLevels.join(', ')}`);
83
+ console.log(' session pool: package default');
84
+ console.log('');
85
+ }
86
+ function printResultSummary(rows) {
87
+ if (rows.length === 0)
88
+ return;
89
+ const successfulRows = rows.filter(row => row.stats.errors === 0);
90
+ const throughputRows = successfulRows.length !== 0 ? successfulRows : rows;
91
+ const latencyRows = successfulRows.length !== 0 ? successfulRows : rows;
92
+ const bestThroughput = throughputRows.reduce((best, row) => (row.stats.opsPerSecond > best.stats.opsPerSecond ? row : best));
93
+ const bestP95 = latencyRows.reduce((best, row) => (row.stats.p95Ms < best.stats.p95Ms ? row : best));
94
+ console.log('');
95
+ console.log('Summary');
96
+ console.log(` best throughput: concurrency=${bestThroughput.concurrency}, ${formatRate(bestThroughput.stats.opsPerSecond)}`);
97
+ console.log(` best p95 latency: concurrency=${bestP95.concurrency}, ${formatDuration(bestP95.stats.p95Ms)}`);
98
+ if (rows.some(row => row.stats.errors !== 0)) {
99
+ console.log(' note: some runs had errors; best-row picks prefer zero-error runs when available');
100
+ }
101
+ }
102
+ function formatLiveProgress(concurrency, completed, total, running) {
103
+ const wallClockMs = performance.now() - running.startedAt;
104
+ const stats = summarizeDurations(running.durations, running.errors, wallClockMs);
105
+ return [
106
+ `conc=${concurrency}`,
107
+ `progress=${completed}/${total}`,
108
+ `avg=${formatRate(stats.opsPerSecond)}`,
109
+ `p50=${formatDuration(stats.p50Ms)}`,
110
+ `p95=${formatDuration(stats.p95Ms)}`,
111
+ `errs=${formatInteger(running.errors)}`
112
+ ].join(' ');
113
+ }
114
+ function printLiveProgress(concurrency, completed, total, running) {
115
+ const line = formatLiveProgress(concurrency, completed, total, running);
116
+ process.stdout.write(`\r${line}`);
117
+ if (completed === total)
118
+ process.stdout.write('\n');
119
+ }
120
+ function printRunStart(concurrency, iterations, warmup) {
121
+ console.log(`Run start: conc=${concurrency}, iterations/worker=${iterations}, warmup/worker=${warmup}`);
122
+ }
123
+ function printRunEnd(concurrency, stats) {
124
+ console.log(`Run done: conc=${concurrency}, ${formatRate(stats.opsPerSecond)}, p50=${formatDuration(stats.p50Ms)}, p95=${formatDuration(stats.p95Ms)}, errs=${formatInteger(stats.errors)}`);
125
+ console.log('');
126
+ }
127
+ function buildFindRequest(field, operator, value) {
128
+ return {
129
+ [field]: query([operator, ''], value)
130
+ };
131
+ }
132
+ async function main() {
133
+ const program = new Command();
134
+ const argv = process.argv[2] === '--'
135
+ ? [process.argv[0], process.argv[1], ...process.argv.slice(3)]
136
+ : process.argv;
137
+ program
138
+ .description('Stress-test FileMaker search/list throughput for easy-fm')
139
+ .requiredOption('--layout <name>', 'layout name to query')
140
+ .option('--field <name>', 'field to search')
141
+ .option('--value <value>', 'value to search for')
142
+ .option('--operator <operator>', 'FileMaker query operator', '=')
143
+ .option('--mode <mode>', 'find or list', 'find')
144
+ .option('--limit <number>', 'result limit per request', '100')
145
+ .option('--iterations <number>', 'operations per worker', '20')
146
+ .option('--warmup <number>', 'warmup operations per worker', '2')
147
+ .addOption(new Option('--concurrency <list>', 'comma-separated concurrency levels').default('1,2,4,8'))
148
+ .option('--debug', 'enable easy-fm debug logging', false);
149
+ program.parse(argv);
150
+ const options = program.opts();
151
+ if (options.mode === 'find' && (!options.field || typeof options.value === 'undefined')) {
152
+ throw new Error('--field and --value are required when --mode=find');
153
+ }
154
+ const host = process.env.FM_DB_HOST;
155
+ const database = process.env.FM_DB_NAME;
156
+ const username = process.env.FM_DB_ACCOUNT;
157
+ const password = process.env.FM_DB_PASSWORD;
158
+ if (!host || !database || !username || !password) {
159
+ throw new Error('Missing FM_DB_HOST, FM_DB_NAME, FM_DB_ACCOUNT, or FM_DB_PASSWORD in environment');
160
+ }
161
+ const concurrencyLevels = parseIntegerList(options.concurrency);
162
+ const limit = parseInt(options.limit, 10);
163
+ const iterations = parseInt(options.iterations, 10);
164
+ const warmup = parseInt(options.warmup, 10);
165
+ printRunSummary({
166
+ mode: options.mode,
167
+ layout: options.layout,
168
+ database,
169
+ host,
170
+ field: options.field,
171
+ operator: options.operator,
172
+ value: options.value,
173
+ limit,
174
+ iterations,
175
+ warmup,
176
+ concurrencyLevels
177
+ });
178
+ const fmHost = new FMHost(host, false);
179
+ const rows = [];
180
+ const connection = fmHost.database({
181
+ database,
182
+ credentials: {
183
+ method: 'filemaker',
184
+ username,
185
+ password
186
+ },
187
+ externalSources: [],
188
+ debug: options.debug
189
+ });
190
+ try {
191
+ const layout = connection.layout(options.layout);
192
+ await layout.getLayoutMeta();
193
+ for (const concurrency of concurrencyLevels) {
194
+ printRunStart(concurrency, iterations, warmup);
195
+ const runOperation = async () => {
196
+ const startedAt = performance.now();
197
+ try {
198
+ const request = layout.records.list({ portals: {}, limit });
199
+ if (options.mode === 'find') {
200
+ request.addRequest(buildFindRequest(options.field, options.operator, options.value));
201
+ }
202
+ await request.fetch();
203
+ return { durationMs: performance.now() - startedAt, error: false };
204
+ }
205
+ catch {
206
+ return { durationMs: performance.now() - startedAt, error: true };
207
+ }
208
+ };
209
+ for (let i = 0; i < warmup * concurrency; i++) {
210
+ await runOperation();
211
+ }
212
+ const totalOperations = concurrency * iterations;
213
+ let completed = 0;
214
+ const running = {
215
+ durations: [],
216
+ errors: 0,
217
+ startedAt: performance.now()
218
+ };
219
+ await Promise.all(Array.from({ length: concurrency }, async () => {
220
+ for (let i = 0; i < iterations; i++) {
221
+ const result = await runOperation();
222
+ running.durations.push(result.durationMs);
223
+ if (result.error)
224
+ running.errors += 1;
225
+ completed += 1;
226
+ printLiveProgress(concurrency, completed, totalOperations, running);
227
+ }
228
+ }));
229
+ const stats = summarizeDurations(running.durations, running.errors, performance.now() - running.startedAt);
230
+ rows.push({ concurrency, stats });
231
+ printRunEnd(concurrency, stats);
232
+ }
233
+ }
234
+ finally {
235
+ await connection.logout();
236
+ }
237
+ console.log(buildTable(rows));
238
+ printResultSummary(rows);
239
+ }
240
+ void main().catch(error => {
241
+ console.error(error);
242
+ process.exitCode = 1;
243
+ });
@@ -0,0 +1,19 @@
1
+ export declare class CookieJar {
2
+ #private;
3
+ static clone(jar: CookieJar): CookieJar;
4
+ /**
5
+ * Parses a Set-Cookie header value and adds it to the jar
6
+ * Expected format: <name>=<value>; Attribute; Attribute=value; ...
7
+ * Example: session=abc123; Domain=example.com; Path=/; Secure; SameSite=Lax
8
+ */
9
+ addCookie(source: URL, cookie: string): void;
10
+ /**
11
+ * getCookies returns all cookies for a given URL
12
+ * @param url
13
+ */
14
+ getCookies(url: URL): Map<string, string>;
15
+ /**
16
+ * Builds the 'Cookie' header to attach to a request
17
+ */
18
+ getCookieHeader(url: URL): string;
19
+ }
@@ -0,0 +1,99 @@
1
+ export class CookieJar {
2
+ #cookies = new Map();
3
+ static #normalizeDomain(domain) {
4
+ return domain.trim().replace(/^\./, '').replace(/^www\./, '').toLowerCase();
5
+ }
6
+ static clone(jar) {
7
+ const newJar = new CookieJar();
8
+ for (const [name, cookie] of jar.#cookies) {
9
+ newJar.#cookies.set(name, cookie);
10
+ }
11
+ return newJar;
12
+ }
13
+ /**
14
+ * Parses a Set-Cookie header value and adds it to the jar
15
+ * Expected format: <name>=<value>; Attribute; Attribute=value; ...
16
+ * Example: session=abc123; Domain=example.com; Path=/; Secure; SameSite=Lax
17
+ */
18
+ addCookie(source, cookie) {
19
+ const parts = cookie.split(';').map(part => part.trim()).filter(Boolean);
20
+ const [nameValue, ...attributes] = parts;
21
+ const separatorIndex = nameValue.indexOf('=');
22
+ if (separatorIndex === -1)
23
+ return;
24
+ const name = nameValue.slice(0, separatorIndex);
25
+ const value = nameValue.slice(separatorIndex + 1);
26
+ const cookieMap = new Map();
27
+ for (const attribute of attributes) {
28
+ const index = attribute.indexOf('=');
29
+ if (index === -1) {
30
+ cookieMap.set(attribute, 'true');
31
+ continue;
32
+ }
33
+ cookieMap.set(attribute.slice(0, index), attribute.slice(index + 1));
34
+ }
35
+ // 2. Convert the map to a Cookie object
36
+ const maxAgeRaw = cookieMap.get('Max-Age');
37
+ const expiresRaw = cookieMap.get('Expires');
38
+ let expires = null;
39
+ if (maxAgeRaw) {
40
+ expires = new Date(Date.now() + parseInt(maxAgeRaw) * 1000);
41
+ }
42
+ else if (expiresRaw) {
43
+ const tempExpires = new Date(expiresRaw);
44
+ if (isNaN(tempExpires.getTime())) {
45
+ console.warn('Invalid Expires date:', expiresRaw);
46
+ }
47
+ else {
48
+ expires = tempExpires;
49
+ }
50
+ }
51
+ const cookieObj = {
52
+ name,
53
+ value,
54
+ domain: CookieJar.#normalizeDomain(cookieMap.get('Domain') ?? source.hostname),
55
+ path: cookieMap.get('Path') ?? '/',
56
+ expires: expires?.getTime() ?? null,
57
+ secure: cookieMap.get('Secure') === 'true',
58
+ httpOnly: cookieMap.get('HttpOnly') === 'true',
59
+ sameSite: cookieMap.get('SameSite') ?? 'Lax',
60
+ partitioned: cookieMap.get('Partitioned') === 'true',
61
+ priority: cookieMap.get('Priority') ?? 'Medium'
62
+ };
63
+ this.#cookies.set(cookieObj.name, cookieObj);
64
+ }
65
+ /**
66
+ * getCookies returns all cookies for a given URL
67
+ * @param url
68
+ */
69
+ getCookies(url) {
70
+ const domain = CookieJar.#normalizeDomain(url.hostname);
71
+ const result = new Map();
72
+ const now = Date.now();
73
+ for (const [name, cookie] of this.#cookies) {
74
+ if (cookie.expires !== null && cookie.expires < now) {
75
+ // Remove the cookie if it has expired
76
+ this.#cookies.delete(name);
77
+ continue;
78
+ }
79
+ if (cookie.domain !== domain &&
80
+ !domain.endsWith(`.${cookie.domain}`))
81
+ continue;
82
+ if (cookie.path && !url.pathname.startsWith(cookie.path))
83
+ continue;
84
+ if (cookie.secure && url.protocol !== 'https:')
85
+ continue;
86
+ result.set(cookie.name, cookie.value);
87
+ }
88
+ return result;
89
+ }
90
+ /**
91
+ * Builds the 'Cookie' header to attach to a request
92
+ */
93
+ getCookieHeader(url) {
94
+ const cookies = this.getCookies(url);
95
+ if (cookies.size === 0)
96
+ return '';
97
+ return Array.from(cookies.entries()).map(([name, value]) => `${name}=${value}`).join('; ');
98
+ }
99
+ }