@vida-global/core 2.0.1 → 2.0.4

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 CHANGED
@@ -1,10 +1,11 @@
1
1
  # Vida/Core
2
- This package is intended to contain core elements used across all Vida Apps. Keep in mind that it's included in the `vida-apps-tools` package, so customers developing Vida Apps will have access to it.
2
+ This package contains core elements used across all Vida Apps. It is included in the `vida-apps-tools` package, so customers developing Vida Apps have access to it.
3
3
 
4
- # Guides
5
4
 
6
- - [ActiveRecord](lib/active_record)
7
- - [HttpClient](lib/http)
8
- - [Logger](lib/logger)
9
- - [VidaServer](lib/server)
10
- - [JobQueue](lib/jobQueue)
5
+ ## Guides
6
+
7
+ - [ActiveRecord](lib/activeRecord) — relational model layer over Sequelize with configuration, migrations, querying, virtual fields, and optional Redis caching.
8
+ - [HttpClient](lib/http) — base class for building typed HTTP clients with shared headers, request/response handling, aborts, and error inspection.
9
+ - [Logger](lib/logger) — scoped, child-aware logger with environment-driven levels.
10
+ - [VidaServer](lib/server) — Express-based server with auto-loaded controllers, validation, rendering, callbacks, cookies, and SSE streaming.
11
+ - [JobQueue](lib/jobQueue) — BullMQ-backed queue with workers, retries/backoff, progress reporting, and queue inspection helpers.
@@ -86,6 +86,8 @@ exports.config = {
86
86
  // the platform's log pipeline.
87
87
  application_logging: { enabled: false },
88
88
 
89
+ logging: { enabled: false },
90
+
89
91
  /**
90
92
  * Ignore routine, non-business routes so they don't dominate transaction
91
93
  * lists or skew apdex. Patterns are anchored regexes matched against the
@@ -1,118 +1,104 @@
1
- # Configuration
2
- To configure your database, create a `config.js` file in the `config/db/` directory of your project. That file should follow this format:
3
- ```
1
+ # ActiveRecord
2
+ `ActiveRecord` is a thin wrapper around [Sequelize](https://sequelize.org/docs/v6/) that adds Vida conventions: snake_cased schema, automatic `id`/`created_at`/`updated_at` columns, optional Redis-backed record caching, virtual/override field accessors, and a migration CLI. Direct Sequelize features (associations, scopes, validators, hooks) continue to work as documented upstream.
3
+
4
+
5
+ ## Setup
6
+
7
+ Create `config/db/config.js` at the root of your project. The file maps **database IDs** (the `default` ID is used unless a model overrides `static get databaseId`) to per-environment connection details.
8
+
9
+ ```js
4
10
  module.exports = {
5
11
  default: {
6
12
  development: {
7
13
  database: process.env.DATABASE,
8
- host: process.env.DB_HOST,
14
+ host: process.env.DB_HOST,
9
15
  password: process.env.DB_PASSWORD,
10
- username: process.env.DB_USERNAME
11
- },
12
- production: {
13
- ...
16
+ username: process.env.DB_USERNAME,
14
17
  },
15
- ...
16
- }
17
- }
18
- ```
19
-
20
- Add additional entries for multiple databases
21
- ```
22
- module.exports = {
23
- default: {
24
- ...
18
+ production: { /* ... */ },
25
19
  },
20
+
21
+ // Additional databases get their own top-level key.
26
22
  metrics: {
27
- development: {
28
- database: process.env.DATABASE,
29
- host: process.env.DB_HOST,
30
- password: process.env.DB_PASSWORD,
31
- username: process.env.DB_USERNAME
32
- },
33
- production: {
34
- ...
35
- },
36
- ...
23
+ development: { /* ... */ },
24
+ production: { /* ... */ },
37
25
  },
38
- ...
39
26
  }
40
27
  ```
41
28
 
29
+ ### Connection pooling
42
30
 
43
- ### Connection Pooling
44
- Minimum and maximum connection pool sizes can be configured in config.js, but default to 0 and 5, respectively.
45
- ```
31
+ Min/max pool sizes default to `0` and `5`. Override per environment.
32
+
33
+ ```js
46
34
  development: {
47
35
  database: process.env.DATABASE,
48
- host: process.env.DB_HOST,
36
+ host: process.env.DB_HOST,
49
37
  password: process.env.DB_PASSWORD,
50
38
  username: process.env.DB_USERNAME,
51
- pool: {
52
- min: 1,
53
- max: 10
54
- }
39
+ pool: { min: 1, max: 10 },
55
40
  }
56
41
  ```
57
42
 
43
+ ### Read replicas
58
44
 
59
- ### Replication
60
- Read replicas can be configured by adding a `readers` key to your configuration
61
- ```
45
+ Add a `readers` array to route reads against replicas.
46
+
47
+ ```js
62
48
  development: {
63
49
  database: process.env.DATABASE,
64
- host: process.env.DB_HOST,
50
+ host: process.env.DB_HOST,
65
51
  password: process.env.DB_PASSWORD,
66
52
  username: process.env.DB_USERNAME,
67
53
  readers: [
68
- {
69
- database: ...
70
- host: ...
71
- password: ...
72
- username: ...
73
- },
74
- {
75
- ...
76
- }
77
- }
54
+ { database: '...', host: '...', password: '...', username: '...' },
55
+ { /* ... */ },
56
+ ],
78
57
  }
79
58
  ```
80
59
 
81
60
 
82
- # Migrations
83
- To prepare your project to run database migrations, add the following lines to the `scripts` section of your `package.json`:
84
- ```
85
- "db:create_migration": "node node_modules/@vida-global/core/scripts/activeRecord/migrate.js create_migration",
86
- "db:migrate": "node node_modules/@vida-global/core/scripts/activeRecord/migrate.js migrate",
87
- "db:rollback": "node node_modules/@vida-global/core/scripts/activeRecord/migrate.js rollback"
88
- ```
61
+ ## Migrations
89
62
 
90
- To generate a new migration file, run: `npm run db:create_migration createUsers`. This will automatically generate a migration file. Your migration can create tables, create indexes, and add, remove, or update columns.
63
+ Wire up the migration CLI by adding scripts to `package.json`:
64
+
65
+ ```json
66
+ "scripts": {
67
+ "db:create_migration": "node node_modules/@vida-global/core/scripts/activeRecord/migrate.js create_migration",
68
+ "db:migrate": "node node_modules/@vida-global/core/scripts/activeRecord/migrate.js migrate",
69
+ "db:rollback": "node node_modules/@vida-global/core/scripts/activeRecord/migrate.js rollback"
70
+ }
91
71
  ```
72
+
73
+ Generate a migration with `npm run db:create_migration createUsers`. Pass `-- --databaseId metrics` to scope the migration to a non-default database. Run `npm run db:migrate` to apply migrations or `npm run db:rollback` to undo the most recent one.
74
+
75
+ ```js
92
76
  module.exports = {
93
77
  up: async function() {
94
78
  await this.createTable('users', {
95
- email: this.DataTypes.STRING,
96
- team_id: this.DataTypes.INTEGER,
79
+ email: this.DataTypes.STRING,
80
+ team_id: this.DataTypes.INTEGER,
97
81
  is_admin: {
98
- type: this.DataTypes.BOOLEAN,
82
+ type: this.DataTypes.BOOLEAN,
99
83
  defaultValue: false,
100
- allowNull: false,
84
+ allowNull: false,
101
85
  }
102
86
  });
103
87
 
104
- await this.addIndex('users', ['team_id'], {where: {team_id: {[this.Operators.ne]: null}}});
105
- await this.addIndex('users', ['email'], {unique: true});
88
+ await this.addIndex('users', ['team_id'], { where: { team_id: { [this.Operators.ne]: null } } });
89
+ await this.addIndex('users', ['email'], { unique: true });
106
90
  },
107
91
 
108
- down: async function () {
92
+ down: async function() {
109
93
  await this.dropTable('users');
110
94
  }
111
95
  }
112
96
  ```
113
- Available migrations are:
114
- ```
115
- createTable(tableName, details)
97
+
98
+ Available migration helpers:
99
+
100
+ ```js
101
+ createTable(tableName, details, { timestamps })
116
102
  dropTable(tableName)
117
103
  addColumn(tableName, columnName, columnDetails)
118
104
  removeColumn(tableName, columnName)
@@ -121,22 +107,26 @@ removeIndex(tableName, indexNameOrAttributes, concurrently=false)
121
107
  renameColumn(tableName, oldName, newName)
122
108
  changeColumn(tableName, columnName, dataTypeOrOptions)
123
109
  ```
124
- To run your migrations, run `npm run db:migrate` or `npm run db:rollback` to rollback a single migration.
125
110
 
126
- **NOTE**: By default, `createTable` automatically adds an autoincrement, primary key `id` column, a `created_at` column, and an `updated_at` column.
111
+ **Conventions:**
127
112
 
113
+ - `createTable` adds an auto-increment `id` primary key and `created_at` / `updated_at` columns by default. Pass `{ timestamps: false }` to skip the timestamps.
114
+ - Column names are automatically converted to snake_case, so `teamId` in the details object becomes `team_id` in the database.
115
+ - Each migration runs inside a transaction.
128
116
 
129
- # Models
130
- The following code will create a basic model representing a user in our database:
131
- ```
132
- const { ActiveRecord } = require('@vida-global/core')
117
+
118
+ ## Models
119
+
120
+ Subclass `BaseRecord` and call `initialize()`. The framework reads the table schema and configures the model.
121
+
122
+ ```js
123
+ const { ActiveRecord } = require('@vida-global/core');
133
124
 
134
125
  class User extends ActiveRecord.BaseRecord {
135
126
  }
136
-
137
127
  User.initialize();
138
128
 
139
- const user = new User({email: 'mark@vida.inc', team_id: 1, is_admin: false});
129
+ const user = new User({ email: 'mark@vida.inc', team_id: 1, is_admin: false });
140
130
  await user.save();
141
131
 
142
132
  user.is_admin = true;
@@ -145,61 +135,180 @@ await user.save();
145
135
  await user.destroy();
146
136
  ```
147
137
 
138
+ ### Querying
148
139
 
149
- ### Querying Models
150
- ```
151
- const users = await User.findAll();
152
- const users = await User.findAll{where: {team_id: 1}});
153
- const users = await User.findAll{where: User.Operator
140
+ ```js
141
+ const allUsers = await User.findAll();
142
+ const teamUsers = await User.findAll({ where: { team_id: 1 } });
154
143
 
155
- const users = await User.findAll({where: {
156
- team_id: {
157
- [Recording.Operators.or]: [1,2]
158
- }
159
- }});
144
+ const eitherTeam = await User.findAll({ where: {
145
+ team_id: { [User.Operators.or]: [1, 2] }
146
+ } });
160
147
 
161
- ```
148
+ const { count, rows } = await User.findAndCountAll({
149
+ where: { email: { [User.Operators.like]: '%@vida.inc' } },
150
+ offset: 0,
151
+ limit: 10,
152
+ });
162
153
 
154
+ const totalCount = await User.count({ where: { team_id: 1 } });
163
155
 
164
- ### Aggregators and Functions
156
+ // Convenience for findByPk / list-by-pk
157
+ const byId = await User.find(1);
158
+ const byIds = await User.find([1, 2, 3]);
165
159
  ```
160
+
161
+ More query examples: [Sequelize model querying basics](https://sequelize.org/docs/v6/core-concepts/model-querying-basics/).
162
+
163
+ ### Aggregators and functions
164
+
165
+ Sequelize's `fn` and `col` helpers are exposed on the model class:
166
+
167
+ ```js
166
168
  const rows = await User.findAll({
167
169
  attributes: ['team_id', [User.fn('MAX', User.col('id')), 'max_id']],
168
- group: 'team_id'
170
+ group: 'team_id',
169
171
  });
172
+ ```
170
173
 
171
- const { count, users } = await User.findAndCountAll({
172
- where: {
173
- email: {
174
- [User.Operators.like]: '%@vida.inc',
175
- },
176
- },
177
- offset: 0,
178
- limit: 1,
174
+ ### API serialization
175
+
176
+ `record.toApiResponse()` returns a plain object containing every column with overridden getters applied. The server's response renderer calls this automatically when a record is included in an action's return value.
177
+
178
+ ### Closing connections
179
+
180
+ ```js
181
+ await User.closeConnection(); // close the connection for this model's database
182
+ await User.closeAllConnections(); // close every connection (all databases, all models)
183
+ ```
184
+
185
+
186
+ ## Custom field accessors
187
+
188
+ Define `_get${ColumnName}` / `_set${ColumnName}` on the class to override the read/write behavior of a column. The framework wires them up automatically when the model initializes.
189
+
190
+ ```js
191
+ class User extends ActiveRecord.BaseRecord {
192
+ _getEmail() {
193
+ return this.getDataValue('email')?.toLowerCase();
194
+ }
195
+
196
+ _setEmail(value) {
197
+ this.setDataValue('email', value?.trim());
198
+ }
199
+ }
200
+ User.initialize();
201
+ ```
202
+
203
+
204
+ ## Virtual fields
205
+
206
+ Add a derived attribute by defining `_virtualGet${PropertyName}` (and optionally `_virtualSet${PropertyName}`). The framework registers a Sequelize `VIRTUAL` column for it, so the property appears in `dataValues`, `toJSON()`, and `toApiResponse()`. Virtual accessors are inherited through the class hierarchy.
207
+
208
+ ```js
209
+ class User extends ActiveRecord.BaseRecord {
210
+ _virtualGetFullName() {
211
+ return `${this.first_name} ${this.last_name}`;
212
+ }
213
+ }
214
+ User.initialize();
215
+
216
+ const user = await User.find(1);
217
+ user.fullName; // "Ada Lovelace"
218
+ ```
219
+
220
+
221
+ ## Caching
222
+
223
+ Opt a model in to Redis-backed caching by overriding `isCacheable`. With caching on:
224
+
225
+ - `Model.find(id)` and `Model.find([id1, id2])` consult Redis first and fall back to the database for misses, repopulating the cache for next time.
226
+ - `afterSave` and `afterDestroy` hooks automatically invalidate the cached record.
227
+ - Cache keys are namespaced as `AR:<ClassName>` (`<ClassName>#find:<id>`).
228
+
229
+ ```js
230
+ class User extends ActiveRecord.BaseRecord {
231
+ static get isCacheable() { return true; }
232
+ }
233
+ User.initialize();
234
+
235
+ const user = await User.find(1); // cache miss; reads DB and caches
236
+ const same = await User.find(1); // cache hit
237
+ const fresh = await User.find(1, { clear: true }); // forces DB and refreshes the cache
238
+ ```
239
+
240
+ Use `cachedIds(key, where)` to cache the result of a list query keyed by an arbitrary string:
241
+
242
+ ```js
243
+ const adminIds = await User.cachedIds('admins', { is_admin: true });
244
+ ```
245
+
246
+ A record can be evicted manually:
247
+
248
+ ```js
249
+ await user.clearSelfCache();
250
+ ```
251
+
252
+ Override `updateCache(options)` on the model to perform additional cache invalidation alongside the automatic single-record eviction.
253
+
254
+ This feature requires the Redis client wired up via `lib/redis`.
255
+
256
+
257
+ ## Hooks
258
+
259
+ `BaseRecord` inherits Sequelize's full hook system. Register a hook on the model class with `addHook`:
260
+
261
+ ```js
262
+ User.addHook('beforeCreate', async (user, options) => {
263
+ user.api_token = await generateToken();
179
264
  });
180
265
 
181
- const totalCount = await User.count({where: {team_id: 1}});
266
+ User.addHook('afterSave', async (user, options) => {
267
+ await broadcast('users.changed', { id: user.id });
268
+ });
182
269
  ```
183
- More examples can be found here, https://sequelize.org/docs/v6/core-concepts/model-querying-basics/.
184
270
 
185
- **Note** The `sequelize.fn`, `sequelize.col`, and `Op` properties have been added as helpers on the Model (e.g. `User.fn('MAX', User.col('id'), 'max_id')` and `User.Operators.or`)
271
+ See [Sequelize hooks](https://sequelize.org/docs/v6/other-topics/hooks/) for the full list of hook points. The caching layer above uses `afterSave` and `afterDestroy` under the hood when `isCacheable` is true.
272
+
273
+
274
+ ## Associations
275
+
276
+ Define associations using Sequelize's standard API after the model is initialized:
277
+
278
+ ```js
279
+ User.initialize();
280
+ Team.initialize();
186
281
 
282
+ User.belongsTo(Team, { foreignKey: 'team_id' });
283
+ Team.hasMany(User, { foreignKey: 'team_id' });
284
+ ```
187
285
 
188
- ### Associations
189
- **TODO**
286
+ See [Sequelize associations](https://sequelize.org/docs/v6/core-concepts/assocs/) for the full reference.
190
287
 
191
288
 
192
- ### Validators
193
- **TODO**
289
+ ## Validators
194
290
 
291
+ Sequelize's column-level validators continue to work — declare them on the model's column definition or via the `validate` option:
195
292
 
196
- ### Hooks
197
- **TODO**
293
+ ```js
294
+ class User extends ActiveRecord.BaseRecord {
295
+ static get initializationOptions() {
296
+ const opts = super.initializationOptions;
297
+ opts.options.validate = {
298
+ adminMustHaveTeam() {
299
+ if (this.is_admin && !this.team_id) {
300
+ throw new Error('admin users must belong to a team');
301
+ }
302
+ }
303
+ };
304
+ return opts;
305
+ }
306
+ }
307
+ ```
198
308
 
309
+ See [Sequelize validators](https://sequelize.org/docs/v6/core-concepts/validations-and-constraints/).
199
310
 
200
- ### Paranoid
201
- **TODO**
202
311
 
312
+ ## TODO
203
313
 
204
- # Closing Connections
205
- When cleaning up your application, close all open connections with `User.closeAllConnections()`. This will close all connections for all models, not just the `User` model.
314
+ - **Paranoid (soft delete).** The infrastructure for a `deleted_at` column is wired in `initializationOptions`, but `paranoid: true` is not set, so soft deletes are not yet usable out of the box.
@@ -1,42 +1,107 @@
1
1
  # HttpClient
2
- The `HttpClient` is a generic client for making HTTP requests. It can be used on its own, but is best suited for creating subclasses intended for specific APIs. By overriding the `urlRoot` and `defaultHeaders` methods, an API client can be made that reduces duplicated code.
3
- ```
2
+ `HttpClient` is a generic client for making HTTP requests. It is best used as a base class for typed wrappers around a specific API: subclasses override `urlRoot` and `defaultHeaders` and expose one method per endpoint, which keeps third-party SDK details out of the rest of the codebase.
3
+
4
+
5
+ ## Core Concepts
6
+
7
+ Every HTTP call is made through one of four verbs (`get`, `post`, `put`, `delete`) defined on `HttpClient`. Each verb takes an endpoint path and an options object; the client composes that with the subclass's `urlRoot` and `defaultHeaders`. Responses with a status under 300 resolve as `{ data, status }`. Anything else throws an `HttpError`; aborts throw an `HttpAbortError`.
8
+
9
+
10
+ ## Usage
11
+
12
+ Subclass `HttpClient` and expose endpoint methods.
13
+
14
+ ```js
15
+ const { HttpClient } = require('@vida-global/core');
16
+
4
17
  class MyApiClient extends HttpClient {
5
- constructor({dev, token}) {
18
+ constructor({ dev, token }) {
19
+ super();
6
20
  this.#token = token;
7
21
  this.#dev = dev;
8
22
  }
9
23
 
24
+ get urlRoot() {
25
+ return this.#dev ? 'https://staging.foo.com' : 'https://foo.com';
26
+ }
27
+
10
28
  get defaultHeaders() {
11
29
  const headers = super.defaultHeaders;
12
30
  headers.Authorization = `Bearer ${this.#token}`;
13
31
  return headers;
14
32
  }
15
33
 
16
- get urlRoot() {
17
- if (this.#dev) {
18
- return "https://staging.foo.com";
19
- } else {
20
- return "https://foo.com";
21
- }
34
+ async getTickets({ page, pageSize }) {
35
+ return await this.get('/tickets', { requestParams: { page, pageSize } });
22
36
  }
23
- }
24
37
 
25
- const client = new MyApiClient(true, '123abctoken');
26
- const requestParameters = {baz: 1, ban: 2};
27
- const response = await client.get("/foo/bar", {requestParameters});
38
+ async createTicket(ticket) {
39
+ return await this.post('/tickets', { body: ticket });
40
+ }
41
+ }
42
+ ```
28
43
 
29
- const body = {baz: 1, ban: 2};
30
- const response = await client.post("/foo/bar", { body });
44
+ ```js
45
+ const client = new MyApiClient({ dev: true, token: '123abctoken' });
46
+ const response = await client.getTickets({ page: 1, pageSize: 25 });
47
+ // response = { data: <parsed JSON>, status: 200 }
31
48
  ```
32
49
 
50
+ ### Verbs and options
51
+
52
+ | Method | Options |
53
+ |---|---|
54
+ | `get(endpoint, opts)` | `requestParams`, `headers`, `timeout`, `signal` |
55
+ | `post(endpoint, opts)` | `body`, `headers`, `timeout`, `signal` |
56
+ | `put(endpoint, opts)` | `body`, `headers`, `timeout`, `signal` |
57
+ | `delete(endpoint, opts)` | `requestParams`, `headers`, `timeout`, `signal` |
58
+
59
+ For `GET`/`DELETE`, `requestParams` becomes the query string. For `POST`/`PUT`, `body` becomes the request body (serialized as JSON by default).
60
+
61
+
62
+ ### Query string encoding
63
+
64
+ For `GET`/`DELETE`, the way `requestParams` is appended to the URL depends on the `Content-Type` header:
65
+
66
+ - `application/json` (the default) — non-primitive values are `JSON.stringify`'d into a single query-string value.
67
+ - `application/x-www-form-urlencoded` — arrays expand to repeated keys (`?tag=a&tag=b`).
68
+
69
+ Override `defaultHeaders` (or pass a one-off `headers` option) to switch encoding strategies.
70
+
71
+
33
72
  ## Aborting requests
34
- All request methods accept `timeout` (ms) and/or a caller-supplied `signal` (`AbortSignal`). When both are provided, they are composed via `AbortSignal.any` — whichever fires first aborts the request. If the request is aborted (by the timeout or the external signal), an `HttpAbortError` is thrown with `reason` set to either `'timed out'` or `'aborted'`.
35
73
 
36
- ```
37
- await client.post("/foo/bar", { body, timeout: 5000 });
74
+ All request methods accept `timeout` (ms) and/or a caller-supplied `signal` (`AbortSignal`). When both are provided they are composed via `AbortSignal.any` — whichever fires first aborts the request. If the request is aborted, an `HttpAbortError` is thrown with `reason` set to either `'timed out'` or `'aborted'`.
75
+
76
+ ```js
77
+ await client.post('/foo/bar', { body, timeout: 5000 });
38
78
 
39
79
  const ctrl = new AbortController();
40
- await client.get("/foo/bar", { signal: ctrl.signal });
80
+ await client.get('/foo/bar', { signal: ctrl.signal });
81
+ ctrl.abort();
41
82
  ```
42
83
 
84
+
85
+ ## Errors
86
+
87
+ Non-2xx responses throw `HttpError`. Inspect:
88
+
89
+ | Property | Description |
90
+ |---|---|
91
+ | `err.status` | Numeric HTTP status. |
92
+ | `err.statusText` | HTTP reason phrase. |
93
+ | `err.url` | Full request URL. |
94
+ | `err.responseHeaders` | Cloned response headers. |
95
+ | `err.requestPayload` | Cloned outgoing payload (method, headers, body). |
96
+ | `await err.data()` | Parsed response body — JSON when possible, raw text otherwise. |
97
+
98
+ `HttpAbortError` exposes `err.reason` (`'timed out'` or `'aborted'`) and `err.requestPayload`.
99
+
100
+
101
+ ## Advanced
102
+
103
+ Subclasses customize behavior through getters on the class:
104
+
105
+ - `urlRoot` — base URL prepended to every endpoint.
106
+ - `defaultHeaders` — merged into every request (per-call `headers` override).
107
+ - `logger` — scoped logger used for the `API CALL: ...` debug line emitted on each request (defaults to `logger.http`).
package/lib/http/error.js CHANGED
@@ -13,7 +13,7 @@ class HttpError extends Error {
13
13
  get message() { return `HTTP Error: ${this.status}`};
14
14
  get status() { return this.#response.status; }
15
15
  get statusText() { return this.#response.statusText; }
16
- get responseHeaders() { return structuredClone(this.#response.headers); }
16
+ get responseHeaders() { return Object.fromEntries(this.#response.headers.entries()) }
17
17
  get requestPayload() { return structuredClone(this.#requestPayload); }
18
18
  get url() { return this.#response.url; }
19
19