@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.
- package/README.md +60 -196
- package/dist/bin/generateTypes.js +3 -3
- 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 +25 -11
- package/dist/connection/FMHost.js +29 -11
- package/dist/connection/HostBase.d.ts +4 -4
- 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 +24 -4
- package/dist/index.js +15 -1
- 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/layoutInterface.d.ts +2 -1
- package/dist/layouts/layoutRecordManager.d.ts +6 -15
- package/dist/layouts/layoutRecordManager.js +6 -15
- package/dist/models/apiResults.d.ts +209 -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/fields/baseField.d.ts +57 -0
- package/dist/records/fields/baseField.js +59 -0
- package/dist/records/fields/containerField.d.ts +35 -0
- package/dist/records/fields/containerField.js +78 -0
- package/dist/records/fields/field.d.ts +3 -0
- package/dist/records/fields/field.js +1 -0
- package/dist/records/fields/valueField.d.ts +41 -0
- package/dist/records/fields/valueField.js +108 -0
- package/dist/records/getOperations/recordGetOperation.d.ts +7 -4
- package/dist/records/getOperations/recordGetOperation.js +22 -26
- package/dist/records/layoutRecord.d.ts +27 -9
- package/dist/records/layoutRecord.js +84 -97
- package/dist/records/portal.d.ts +1 -5
- package/dist/records/portal.js +0 -4
- package/dist/records/portalBase.d.ts +1 -2
- package/dist/records/portalRecord.d.ts +7 -4
- package/dist/records/portalRecord.js +12 -0
- package/dist/records/recordBase.d.ts +8 -6
- package/dist/records/recordBase.js +32 -33
- package/dist/types.d.ts +42 -18
- 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 +24 -8
- package/dist/utils/query.js +60 -20
- package/dist/utils/temporal.d.ts +9 -0
- package/dist/utils/temporal.js +110 -0
- package/package.json +35 -31
package/README.md
CHANGED
|
@@ -1,229 +1,93 @@
|
|
|
1
|
-
#
|
|
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
|
|
3
|
+
`easy-fm` is a Node.js client for Claris FileMaker Data API.
|
|
37
4
|
|
|
38
|
-
|
|
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
|
-
|
|
9
|
+
- Node.js `22+`
|
|
10
|
+
- FileMaker Data API enabled
|
|
11
|
+
- account with `fmrest` privilege
|
|
12
|
+
- at least one accessible layout
|
|
46
13
|
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
})
|
|
19
|
+
```sh
|
|
20
|
+
npm install @jd-data-limited/easy-fm
|
|
66
21
|
```
|
|
67
22
|
|
|
68
|
-
|
|
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
|
-
|
|
25
|
+
```ts
|
|
26
|
+
import {FMHost} from "@jd-data-limited/easy-fm"
|
|
76
27
|
|
|
77
|
-
|
|
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
|
-
|
|
40
|
+
const layout = database.layout("Contacts_API")
|
|
41
|
+
const records = await layout.records.list({
|
|
42
|
+
portals: {},
|
|
43
|
+
limit: 25
|
|
44
|
+
}).fetch()
|
|
93
45
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
51
|
+
## Important Notes
|
|
111
52
|
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
+
> Updating from v4 to v5? Check the guide here; [`docs/upgrade-to-v5.md`](./docs/upgrade-to-v5.md)
|
|
125
61
|
|
|
126
|
-
|
|
127
|
-
console.log(records)
|
|
128
|
-
```
|
|
62
|
+
Start here:
|
|
129
63
|
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
> using a different ID, use the search method above.
|
|
67
|
+
Task guides:
|
|
134
68
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
75
|
+
API reference:
|
|
142
76
|
|
|
143
|
-
|
|
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
|
-
|
|
148
|
-
record.fields["Field2"].value = "Value here"
|
|
149
|
-
record.fields["Field3"].value = "Value here"
|
|
79
|
+
## Build Docs
|
|
150
80
|
|
|
151
|
-
|
|
81
|
+
```sh
|
|
82
|
+
npm run docs:build
|
|
152
83
|
```
|
|
153
84
|
|
|
154
|
-
|
|
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
|
-
|
|
87
|
+
```sh
|
|
88
|
+
npm test
|
|
89
|
+
npm run test:unit
|
|
90
|
+
npm run test:integration
|
|
165
91
|
```
|
|
166
92
|
|
|
167
|
-
|
|
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
|
|
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,
|
|
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,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
|
+
}
|