velocious 1.0.569 → 1.0.571
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 +56 -2
- package/build/configuration-types.js +17 -0
- package/build/configuration.js +70 -0
- package/build/database/drivers/base.js +82 -44
- package/build/database/operation-connection.js +11 -0
- package/build/database/operation.js +10 -0
- package/build/http-server/client/index.js +77 -4
- package/build/http-server/client/response-compression.js +224 -0
- package/build/http-server/client/response.js +57 -0
- package/build/http-server/websocket-events-host.js +91 -48
- package/build/src/configuration-types.d.ts +55 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +16 -1
- package/build/src/configuration.d.ts +6 -0
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +61 -1
- package/build/src/database/drivers/base.d.ts +30 -3
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +78 -42
- package/build/src/database/operation-connection.d.ts +6 -0
- package/build/src/database/operation-connection.d.ts.map +1 -1
- package/build/src/database/operation-connection.js +10 -1
- package/build/src/database/operation.d.ts +8 -0
- package/build/src/database/operation.d.ts.map +1 -1
- package/build/src/database/operation.js +10 -1
- package/build/src/http-server/client/index.d.ts +17 -1
- package/build/src/http-server/client/index.d.ts.map +1 -1
- package/build/src/http-server/client/index.js +74 -5
- package/build/src/http-server/client/response-compression.d.ts +71 -0
- package/build/src/http-server/client/response-compression.d.ts.map +1 -0
- package/build/src/http-server/client/response-compression.js +197 -0
- package/build/src/http-server/client/response.d.ts +27 -0
- package/build/src/http-server/client/response.d.ts.map +1 -1
- package/build/src/http-server/client/response.js +49 -1
- package/build/src/http-server/websocket-events-host.d.ts +28 -11
- package/build/src/http-server/websocket-events-host.d.ts.map +1 -1
- package/build/src/http-server/websocket-events-host.js +84 -46
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -3
- package/scripts/docker-run.sh +20 -0
- package/scripts/verify-docker-dev-environment.js +579 -0
- package/src/configuration-types.js +17 -0
- package/src/configuration.js +70 -0
- package/src/database/drivers/base.js +82 -44
- package/src/database/operation-connection.js +11 -0
- package/src/database/operation.js +10 -0
- package/src/http-server/client/index.js +77 -4
- package/src/http-server/client/response-compression.js +224 -0
- package/src/http-server/client/response.js +57 -0
- package/src/http-server/websocket-events-host.js +91 -48
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* Translated model attributes with current-locale relationship sorting (see [docs/translations.md](docs/translations.md))
|
|
27
27
|
* Cross-process broadcast bus for `broadcastToChannel` via `velocious beacon`, including background job runner processes (see [docs/beacon.md](docs/beacon.md))
|
|
28
28
|
* Configurable HTTP server worker handlers plus backpressured, descriptor-only file responses with completion callbacks (see [docs/http-server.md](docs/http-server.md))
|
|
29
|
+
* Default-on buffered HTTP response compression with Brotli/gzip content negotiation, global and per-response opt-outs, and HEAD-correct representation headers (see [docs/http-server.md](docs/http-server.md#response-compression))
|
|
29
30
|
* Background jobs with failure events for production reporting and authorized database-scoped dashboard count snapshots/deltas (see [docs/background-jobs.md](docs/background-jobs.md) and [docs/background-jobs-dashboard.md](docs/background-jobs-dashboard.md))
|
|
30
31
|
* Durable one-off background-job scheduling with exact epoch timestamps (see [docs/scheduled-background-job-enqueue.md](docs/scheduled-background-job-enqueue.md))
|
|
31
32
|
* Rails-style request and database query logging (see [docs/logging.md](docs/logging.md))
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
* In-process driver schema metadata caching (see [docs/schema-metadata-cache.md](docs/schema-metadata-cache.md))
|
|
35
36
|
* Planned local-first shared-resource sync architecture (see [docs/offline-sync.md](docs/offline-sync.md))
|
|
36
37
|
* Selective named database connection checkouts, bounded pool waits, and debugging held connections (see [docs/database-connections.md](docs/database-connections.md))
|
|
37
|
-
* Explicit singular-database operation transactions whose model scopes preserve ownership through records, relationships, lifecycle work, nested savepoints, and commit callbacks (see [docs/operation-scoped-transactions.md](docs/operation-scoped-transactions.md))
|
|
38
|
+
* Explicit singular-database operation transactions whose model scopes preserve ownership through records, relationships, lifecycle work, nested savepoints, pre-commit guards, and commit callbacks (see [docs/operation-scoped-transactions.md](docs/operation-scoped-transactions.md))
|
|
38
39
|
* AbortSignal-driven MySQL/MariaDB query cancellation for raw, model, and cross-tenant aggregate queries (see [docs/database-query-cancellation.md](docs/database-query-cancellation.md))
|
|
39
40
|
* Optional built-in debug endpoint for inspecting server and database connection state (see [docs/debug-endpoint.md](docs/debug-endpoint.md))
|
|
40
41
|
* Optional built-in API manifest endpoint describing every registered frontend-model resource as human- and machine-readable JSON (see [docs/api-manifest-endpoint.md](docs/api-manifest-endpoint.md))
|
|
@@ -63,13 +64,23 @@ await configuration.withTransaction({databaseIdentifier: "default", name: "accep
|
|
|
63
64
|
ticket.setAccepted(true)
|
|
64
65
|
await ticket.save()
|
|
65
66
|
|
|
67
|
+
await operation.beforeCommit(async ({operation: guardedOperation}) => {
|
|
68
|
+
const currentTicket = await guardedOperation
|
|
69
|
+
.forModel(Ticket)
|
|
70
|
+
.findByOrFail({id: ticketId})
|
|
71
|
+
|
|
72
|
+
if (!currentTicket.acceptanceStillOwnedBy(workerId)) {
|
|
73
|
+
throw new Error("Ticket acceptance ownership changed")
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
66
77
|
await operation.afterCommit(async () => {
|
|
67
78
|
await publishAcceptedTicket(ticket.id())
|
|
68
79
|
})
|
|
69
80
|
})
|
|
70
81
|
```
|
|
71
82
|
|
|
72
|
-
Use operation-bound model scopes and their loaded records throughout the callback. `operation.transaction` adds a nested savepoint, and `operation.connection()` is the deliberate escape hatch for owned raw SQL. Cross-database models, same-identifier tenant switches to another physical database, and operation handles used after the callback are rejected. On shared SQLite/SQL.js pools, unrelated work waits for the operation lease, while admission during an already-open ordinary transaction is rejected. See [operation-scoped transactions](docs/operation-scoped-transactions.md) for pool
|
|
83
|
+
Use operation-bound model scopes and their loaded records throughout the callback. `operation.beforeCommit` runs a final operation-owned guard after callback success but before outer commit or nested savepoint release; a rejection rolls back that frame. `operation.transaction` adds a nested savepoint, and `operation.connection()` is the deliberate escape hatch for owned raw SQL. Cross-database models, same-identifier tenant switches to another physical database, and operation handles used after the callback are rejected. On shared SQLite/SQL.js pools, unrelated work waits for the operation lease, while admission during an already-open ordinary transaction is rejected. See [operation-scoped transactions](docs/operation-scoped-transactions.md) for guard, pool, after-commit failure, and migration semantics.
|
|
73
84
|
|
|
74
85
|
# Development
|
|
75
86
|
|
|
@@ -83,6 +94,45 @@ npm run test:expo
|
|
|
83
94
|
|
|
84
95
|
Maintainers cutting a package release must follow the [Velocious release runbook](docs/releasing.md); `npm run release:patch` commits, pushes, and publishes rather than acting as a local-only version command.
|
|
85
96
|
|
|
97
|
+
# Docker development environment
|
|
98
|
+
|
|
99
|
+
The checked-in root `Dockerfile` and `compose.yml` define one canonical `dev` service used by humans, CI, and agent systems alike (see [docs/docker-development-environment.md](docs/docker-development-environment.md)). The image is Ubuntu 26.04 LTS (pinned by digest) with Node.js 24.x from signed NodeSource, the universal apt coding/debugging baseline, and the newest published provider CLIs; it is source-independent — no project source is copied and no project dependencies are installed at image build time.
|
|
100
|
+
|
|
101
|
+
Prerequisites: Docker with the Compose v2 plugin, and this repository checked out at `$DEV_HOME_PATH/velocious` (default `DEV_HOME_PATH`: `/home/dev`).
|
|
102
|
+
|
|
103
|
+
First-use setup: copy `.env.example` to the git-ignored `.env` and set `GH_CONFIG_SOURCE_PATH` to an existing host GitHub CLI config directory:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
cp .env.example .env
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`$DEV_HOME_PATH` must be a dedicated development home that already exists, holds no credentials or secrets, and is owned by (or at least writable by) UID/GID 1000 — the in-container `dev` user. Do not point it at a general host home directory, and do not recursively chown an existing home; the external environment owns safe initial provisioning.
|
|
110
|
+
|
|
111
|
+
Normal usage:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
docker compose up --build --detach dev
|
|
115
|
+
docker compose exec dev bash
|
|
116
|
+
scripts/docker-run.sh npm ci # one-off command in a disposable container
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The dev service preserves the complete `$DEV_HOME_PATH` bind at `/home/dev`, so dependencies, caches, settings, and `node_modules` persist naturally across runs. Install dependencies with the normal package commands inside the service (for example `docker compose exec dev npm ci`), never at image build time.
|
|
120
|
+
|
|
121
|
+
Concurrent isolated instances use the standard Compose project-name contract plus a distinct development home per instance:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
COMPOSE_PROJECT_NAME=velocious-review DEV_HOME_PATH=/srv/dev-homes/review \
|
|
125
|
+
docker compose up --build --detach dev
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
GitHub CLI authentication is the sole authorized credential boundary: the host config directory named by `GH_CONFIG_SOURCE_PATH` is mounted read-only at `/home/dev/.config/gh`, with container-side `GH_CONFIG_DIR` pointing there. Do not add SSH keys or other credential mounts. Kimi (and other provider) credentials are intentionally kept out of the tracked Compose files — they are an external operational override layered on by the calling environment. Threadwire is not installed in the image or the project; it remains parent orchestration resolved through unversioned `npx` outside the container.
|
|
129
|
+
|
|
130
|
+
After changing the Docker artifacts, run the checked-in static contract verifier:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
npm run verify:docker-dev-environment
|
|
134
|
+
```
|
|
135
|
+
|
|
86
136
|
# Code quality (fallow)
|
|
87
137
|
|
|
88
138
|
[fallow](https://github.com/fallow-rs/fallow) analyzes the codebase for unused/dead code, duplication, and complexity. CI runs it as a **regression gate**: it fails only on findings beyond the committed baseline in `fallow-baselines/`, so existing backlog never blocks a PR but new issues do.
|
|
@@ -1821,6 +1871,8 @@ this.getConfiguration().getWebsocketEvents().publish(channel, payload)
|
|
|
1821
1871
|
this.renderJsonArg({status: "published"})
|
|
1822
1872
|
```
|
|
1823
1873
|
|
|
1874
|
+
Publishes are queued per channel: events on the same channel are persisted and dispatched in FIFO order, while a slow or failing channel never delays unrelated channels. `configuration.awaitPendingBroadcasts()` settles once every broadcast accepted before the call has settled. See [docs/websocket-channels.md](docs/websocket-channels.md#publish-ordering-and-failure-semantics) for the full ordering and failure contract.
|
|
1875
|
+
|
|
1824
1876
|
## Websocket channels
|
|
1825
1877
|
|
|
1826
1878
|
You can resolve websocket channel classes from subscribe messages and let them decide which streams to allow:
|
|
@@ -2355,6 +2407,8 @@ When the server runs in the `development` environment, Velocious watches applica
|
|
|
2355
2407
|
|
|
2356
2408
|
Starting the HTTP server creates `tmp/server.lock` under the configured application directory before Beacon, workers, or the TCP listener start. A second server for the same app fails fast with the lock owner details instead of partially starting. Normal shutdown removes the lock; stale locks with a dead local PID are reclaimed automatically, while locks from another host or unreadable metadata should be removed manually only after confirming no server is running. See [docs/http-server.md](docs/http-server.md#server-lock).
|
|
2357
2409
|
|
|
2410
|
+
Buffered string and `Uint8Array` responses are compressed with Brotli (`br`) or gzip by default whenever request negotiation and response eligibility allow — no opt-in is required. Disable compression globally with `httpServer.compression: false` or `httpServer.compression: {enabled: false}`, and tune it with `threshold`/`brotliQuality`/`gzipLevel` overrides. Negotiation honors `Accept-Encoding` q-values, wildcards, and identity semantics (empty `406` when no acceptable representation exists), merges `Accept-Encoding` into `Vary`, and skips streamed `sendFile` responses, already-encoded or `no-transform` responses, server-sent events, partial/range responses, bodyless statuses, and non-allowlisted content types. Transformation is additionally excluded automatically for credentialed traffic and validator-carrying responses — requests with `Authorization`/`Cookie` and responses with `Set-Cookie`, `ETag`, `Digest`, or `Content-Digest` are never compressed (compression-oracle protection, and validators stay application-owned). Controllers opt out per response with `response.disableCompression()`, and HEAD requests compute GET-equivalent representation headers without emitting a body. See [docs/http-server.md](docs/http-server.md#response-compression).
|
|
2411
|
+
|
|
2358
2412
|
# Authorization (CanCan-style)
|
|
2359
2413
|
|
|
2360
2414
|
Define resource classes with an `abilities()` method and use `can` / `cannot` rules to constrain model access.
|
|
@@ -249,8 +249,25 @@
|
|
|
249
249
|
* @property {number} [unreachableReportMs] - Grace window (ms) a beacon connect/disconnect blip must persist before it is reported as a framework-error. Transient outages that recover within this window (e.g. a deploy restarting the broker) are not reported. Defaults to 30000.
|
|
250
250
|
*/
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* @typedef {object} HttpCompressionConfiguration
|
|
254
|
+
* @property {boolean} [enabled] - Whether buffered response compression is enabled. Defaults to true; set false to disable globally.
|
|
255
|
+
* @property {number} [threshold] - Minimum buffered body size in bytes before compression is applied. Defaults to 1024.
|
|
256
|
+
* @property {number} [brotliQuality] - Brotli encoder quality (0-11). Defaults to 4.
|
|
257
|
+
* @property {number} [gzipLevel] - Gzip compression level (0-9). Defaults to 6.
|
|
258
|
+
*/
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* @typedef {object} NormalizedHttpCompressionConfiguration
|
|
262
|
+
* @property {boolean} enabled - Whether buffered HTTP response compression is enabled.
|
|
263
|
+
* @property {number} threshold - Minimum buffered body size in bytes before compression is applied.
|
|
264
|
+
* @property {number} brotliQuality - Brotli encoder quality (0-11).
|
|
265
|
+
* @property {number} gzipLevel - Gzip compression level (0-9).
|
|
266
|
+
*/
|
|
267
|
+
|
|
252
268
|
/**
|
|
253
269
|
* @typedef {object} HttpServerConfiguration
|
|
270
|
+
* @property {boolean | HttpCompressionConfiguration} [compression] - Buffered response compression. Enabled with documented defaults when absent; false or {enabled: false} disables it globally.
|
|
254
271
|
* @property {string} [host] - Hostname to bind the HTTP server to.
|
|
255
272
|
* @property {boolean} [inProcess] - Run HTTP handlers in the main thread instead of worker threads.
|
|
256
273
|
* @property {number} [maxWorkers] - Backward-compatible alias for workers.
|
package/build/configuration.js
CHANGED
|
@@ -120,6 +120,10 @@ const DEFAULT_WEBSOCKET_INBOUND_MAX_PENDING_MESSAGES = 256
|
|
|
120
120
|
const DEFAULT_WEBSOCKET_OUTBOUND_MAX_PENDING_BYTES = 16 * 1024 * 1024
|
|
121
121
|
const DEFAULT_WEBSOCKET_OUTBOUND_MAX_PENDING_FRAMES = 256
|
|
122
122
|
|
|
123
|
+
const DEFAULT_COMPRESSION_THRESHOLD = 1024
|
|
124
|
+
const DEFAULT_COMPRESSION_BROTLI_QUALITY = 4
|
|
125
|
+
const DEFAULT_COMPRESSION_GZIP_LEVEL = 6
|
|
126
|
+
|
|
123
127
|
/**
|
|
124
128
|
* Validates a positive safe integer configuration value.
|
|
125
129
|
* @param {?} value - Configured positive safe integer.
|
|
@@ -136,6 +140,63 @@ function positiveSafeInteger(value, name, defaultValue) {
|
|
|
136
140
|
return value
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Validates an integer configuration value inside an inclusive range.
|
|
145
|
+
* @param {?} value - Configured integer.
|
|
146
|
+
* @param {string} name - Configuration key.
|
|
147
|
+
* @param {number} min - Minimum accepted value (inclusive).
|
|
148
|
+
* @param {number} max - Maximum accepted value (inclusive).
|
|
149
|
+
* @param {number} defaultValue - Default value.
|
|
150
|
+
* @returns {number} - Validated configured or default value.
|
|
151
|
+
*/
|
|
152
|
+
function integerInRange(value, name, min, max, defaultValue) {
|
|
153
|
+
if (value === undefined) return defaultValue
|
|
154
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) {
|
|
155
|
+
throw new TypeError(`${name} must be an integer between ${min} and ${max}`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return value
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Normalizes the buffered HTTP response compression configuration. Compression is
|
|
163
|
+
* enabled by default when the setting is absent; `false` or `{enabled: false}`
|
|
164
|
+
* disables it globally.
|
|
165
|
+
* @param {boolean | import("./configuration-types.js").HttpCompressionConfiguration | undefined} value - Configured compression value.
|
|
166
|
+
* @returns {import("./configuration-types.js").NormalizedHttpCompressionConfiguration} - Normalized compression configuration.
|
|
167
|
+
*/
|
|
168
|
+
function normalizeHttpCompression(value) {
|
|
169
|
+
if (value === undefined || value === true) {
|
|
170
|
+
return {enabled: true, threshold: DEFAULT_COMPRESSION_THRESHOLD, brotliQuality: DEFAULT_COMPRESSION_BROTLI_QUALITY, gzipLevel: DEFAULT_COMPRESSION_GZIP_LEVEL}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (value === false) {
|
|
174
|
+
return {enabled: false, threshold: DEFAULT_COMPRESSION_THRESHOLD, brotliQuality: DEFAULT_COMPRESSION_BROTLI_QUALITY, gzipLevel: DEFAULT_COMPRESSION_GZIP_LEVEL}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
178
|
+
throw new TypeError(`httpServer.compression must be a boolean or an object, got: ${String(value)}`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const {brotliQuality, enabled, gzipLevel, threshold, ...restCompression} = value
|
|
182
|
+
const restCompressionKeys = Object.keys(restCompression)
|
|
183
|
+
|
|
184
|
+
if (restCompressionKeys.length > 0) {
|
|
185
|
+
throw new TypeError(`httpServer.compression received unknown keys: ${restCompressionKeys.join(", ")} (supported: brotliQuality, enabled, gzipLevel, threshold)`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (enabled !== undefined && typeof enabled !== "boolean") {
|
|
189
|
+
throw new TypeError(`httpServer.compression.enabled must be a boolean, got: ${String(enabled)}`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
enabled: enabled ?? true,
|
|
194
|
+
threshold: positiveSafeInteger(threshold, "httpServer.compression.threshold", DEFAULT_COMPRESSION_THRESHOLD),
|
|
195
|
+
brotliQuality: integerInRange(brotliQuality, "httpServer.compression.brotliQuality", 0, 11, DEFAULT_COMPRESSION_BROTLI_QUALITY),
|
|
196
|
+
gzipLevel: integerInRange(gzipLevel, "httpServer.compression.gzipLevel", 0, 9, DEFAULT_COMPRESSION_GZIP_LEVEL)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
139
200
|
export default class VelociousConfiguration {
|
|
140
201
|
/**
|
|
141
202
|
* Close database connections promise.
|
|
@@ -236,6 +297,7 @@ export default class VelociousConfiguration {
|
|
|
236
297
|
|
|
237
298
|
this.httpServer = {
|
|
238
299
|
...(httpServer || {}),
|
|
300
|
+
compression: normalizeHttpCompression(httpServer?.compression),
|
|
239
301
|
websocketInboundQueue: {
|
|
240
302
|
maxPendingBytes: positiveSafeInteger(websocketInboundQueue?.maxPendingBytes, "httpServer.websocketInboundQueue.maxPendingBytes", DEFAULT_WEBSOCKET_INBOUND_MAX_PENDING_BYTES),
|
|
241
303
|
maxPendingMessages: positiveSafeInteger(websocketInboundQueue?.maxPendingMessages, "httpServer.websocketInboundQueue.maxPendingMessages", DEFAULT_WEBSOCKET_INBOUND_MAX_PENDING_MESSAGES)
|
|
@@ -500,6 +562,14 @@ export default class VelociousConfiguration {
|
|
|
500
562
|
return this.cors
|
|
501
563
|
}
|
|
502
564
|
|
|
565
|
+
/**
|
|
566
|
+
* Runs get http server compression.
|
|
567
|
+
* @returns {import("./configuration-types.js").NormalizedHttpCompressionConfiguration} - Normalized buffered response compression configuration.
|
|
568
|
+
*/
|
|
569
|
+
getHttpServerCompression() {
|
|
570
|
+
return this.httpServer.compression
|
|
571
|
+
}
|
|
572
|
+
|
|
503
573
|
/**
|
|
504
574
|
* Runs get cookie secret.
|
|
505
575
|
* @returns {string | undefined} - Cookie secret.
|
|
@@ -42,6 +42,12 @@
|
|
|
42
42
|
* @typedef {Record<string, ?>} QueryRowType
|
|
43
43
|
* @typedef {Array<QueryRowType>} QueryResultType
|
|
44
44
|
*/
|
|
45
|
+
/**
|
|
46
|
+
* TransactionCallbackFrame type.
|
|
47
|
+
* @typedef {object} TransactionCallbackFrame
|
|
48
|
+
* @property {Array<() => void | Promise<void>>} afterCommitCallbacks - Callbacks to merge or run after commit.
|
|
49
|
+
* @property {Array<() => void | Promise<void>>} beforeCommitCallbacks - Guards to run before this frame completes.
|
|
50
|
+
*/
|
|
45
51
|
/**
|
|
46
52
|
* RetryableDatabaseErrorResult type.
|
|
47
53
|
* @typedef {object} RetryableDatabaseErrorResult
|
|
@@ -171,8 +177,8 @@ export default class VelociousDatabaseDriversBase {
|
|
|
171
177
|
idSeq = undefined
|
|
172
178
|
/**
|
|
173
179
|
* Narrows the runtime value to the documented type.
|
|
174
|
-
* @type {
|
|
175
|
-
|
|
180
|
+
* @type {TransactionCallbackFrame[]} */
|
|
181
|
+
_transactionCallbackFrames
|
|
176
182
|
/**
|
|
177
183
|
* Narrows the runtime value to the documented type.
|
|
178
184
|
* @type {Map<string, Promise<?>>} */
|
|
@@ -207,7 +213,7 @@ export default class VelociousDatabaseDriversBase {
|
|
|
207
213
|
this.configuration = configuration
|
|
208
214
|
this.mutex = new Mutex() // Can be used to lock this instance for exclusive use
|
|
209
215
|
this.logger = new Logger(this)
|
|
210
|
-
this.
|
|
216
|
+
this._transactionCallbackFrames = []
|
|
211
217
|
this._transactionsCount = 0
|
|
212
218
|
this._transactionsActionsMutex = new Mutex()
|
|
213
219
|
this._schemaCache = new Map()
|
|
@@ -1068,14 +1074,15 @@ export default class VelociousDatabaseDriversBase {
|
|
|
1068
1074
|
*/
|
|
1069
1075
|
async _runTransactionAttempt(callback, options) {
|
|
1070
1076
|
const savePointName = this.generateSavePointName()
|
|
1071
|
-
/**
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1077
|
+
/** @type {TransactionCallbackFrame} */
|
|
1078
|
+
const callbackFrame = {
|
|
1079
|
+
afterCommitCallbacks: [],
|
|
1080
|
+
beforeCommitCallbacks: []
|
|
1081
|
+
}
|
|
1075
1082
|
let transactionStarted = false
|
|
1076
1083
|
let savePointStarted = false
|
|
1077
1084
|
|
|
1078
|
-
this.
|
|
1085
|
+
this._transactionCallbackFrames.push(callbackFrame)
|
|
1079
1086
|
|
|
1080
1087
|
try {
|
|
1081
1088
|
if (this._transactionsCount == 0) {
|
|
@@ -1088,7 +1095,7 @@ export default class VelociousDatabaseDriversBase {
|
|
|
1088
1095
|
savePointStarted = true
|
|
1089
1096
|
}
|
|
1090
1097
|
} catch (error) {
|
|
1091
|
-
this.
|
|
1098
|
+
this._transactionCallbackFrames.pop()
|
|
1092
1099
|
throw error
|
|
1093
1100
|
}
|
|
1094
1101
|
|
|
@@ -1096,6 +1103,7 @@ export default class VelociousDatabaseDriversBase {
|
|
|
1096
1103
|
|
|
1097
1104
|
try {
|
|
1098
1105
|
result = await callback()
|
|
1106
|
+
await this._runBeforeCommitCallbacks(callbackFrame)
|
|
1099
1107
|
|
|
1100
1108
|
if (savePointStarted) {
|
|
1101
1109
|
this.logger.debug("Release savepoint", savePointName)
|
|
@@ -1113,42 +1121,44 @@ export default class VelociousDatabaseDriversBase {
|
|
|
1113
1121
|
this.logger.debug("Transaction error", error)
|
|
1114
1122
|
}
|
|
1115
1123
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1124
|
+
try {
|
|
1125
|
+
let transactionRolledBack = false
|
|
1126
|
+
|
|
1127
|
+
if (savePointStarted) {
|
|
1128
|
+
this.logger.debug("Rollback savepoint", savePointName)
|
|
1129
|
+
try {
|
|
1130
|
+
await this.rollbackSavePoint(savePointName, options)
|
|
1131
|
+
} catch (savePointError) {
|
|
1132
|
+
const message = savePointError instanceof Error ? savePointError.message : `${savePointError}`
|
|
1133
|
+
|
|
1134
|
+
// MySQL sometimes drops savepoints unexpectedly; fall back to rolling back the full transaction
|
|
1135
|
+
if (message.includes("SAVEPOINT") || message.includes("ER_SP_DOES_NOT_EXIST")) {
|
|
1136
|
+
this.logger.debug("Savepoint rollback failed; rolling back entire transaction instead")
|
|
1137
|
+
await this.rollbackTransaction(options)
|
|
1138
|
+
transactionRolledBack = true
|
|
1139
|
+
} else {
|
|
1140
|
+
throw savePointError
|
|
1141
|
+
}
|
|
1132
1142
|
}
|
|
1133
1143
|
}
|
|
1134
|
-
}
|
|
1135
1144
|
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1145
|
+
// Only roll back if a transaction is still open. A nested savepoint whose rollback failed
|
|
1146
|
+
// falls back to rolling back the whole transaction (above), which already closed it and
|
|
1147
|
+
// dropped the count to 0; rolling back again here would issue a second ROLLBACK and drive
|
|
1148
|
+
// `_transactionsCount` below zero, which would then defeat the outermost deadlock-retry guard.
|
|
1149
|
+
if (transactionStarted && !transactionRolledBack && this._transactionsCount > 0) {
|
|
1150
|
+
this.logger.debug("Rollback transaction")
|
|
1151
|
+
await this.rollbackTransaction(options)
|
|
1152
|
+
}
|
|
1153
|
+
} finally {
|
|
1154
|
+
this._transactionCallbackFrames.pop()
|
|
1143
1155
|
}
|
|
1144
1156
|
|
|
1145
|
-
this._afterCommitCallbackFrames.pop()
|
|
1146
|
-
|
|
1147
1157
|
throw error
|
|
1148
1158
|
}
|
|
1149
1159
|
|
|
1150
1160
|
try {
|
|
1151
|
-
await this.
|
|
1161
|
+
await this._commitTransactionCallbackFrame()
|
|
1152
1162
|
} catch (error) {
|
|
1153
1163
|
throw new VelociousDatabaseAfterCommitCallbackError(error)
|
|
1154
1164
|
}
|
|
@@ -1156,6 +1166,23 @@ export default class VelociousDatabaseDriversBase {
|
|
|
1156
1166
|
return result
|
|
1157
1167
|
}
|
|
1158
1168
|
|
|
1169
|
+
/**
|
|
1170
|
+
* Registers a guard to run after the current transaction callback succeeds and before its
|
|
1171
|
+
* outer commit or nested savepoint release.
|
|
1172
|
+
* @param {() => void | Promise<void>} callback - Guard callback.
|
|
1173
|
+
* @param {Pick<QueryOptions, "operationOwner">} [options] - Callback ownership.
|
|
1174
|
+
* @returns {Promise<void>} - Resolves when the guard has been registered.
|
|
1175
|
+
*/
|
|
1176
|
+
async beforeCommit(callback, options = {}) {
|
|
1177
|
+
await this._waitForOperationLease(options.operationOwner)
|
|
1178
|
+
|
|
1179
|
+
const currentFrame = this._transactionCallbackFrames[this._transactionCallbackFrames.length - 1]
|
|
1180
|
+
|
|
1181
|
+
if (!currentFrame) throw new Error("beforeCommit requires an active transaction")
|
|
1182
|
+
|
|
1183
|
+
currentFrame.beforeCommitCallbacks.push(callback)
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1159
1186
|
/**
|
|
1160
1187
|
* Runs a callback after the surrounding transaction commits.
|
|
1161
1188
|
* If no transaction is active, the callback runs immediately.
|
|
@@ -1166,14 +1193,14 @@ export default class VelociousDatabaseDriversBase {
|
|
|
1166
1193
|
async afterCommit(callback, options = {}) {
|
|
1167
1194
|
await this._waitForOperationLease(options.operationOwner)
|
|
1168
1195
|
|
|
1169
|
-
const currentFrame = this.
|
|
1196
|
+
const currentFrame = this._transactionCallbackFrames[this._transactionCallbackFrames.length - 1]
|
|
1170
1197
|
|
|
1171
1198
|
if (!currentFrame) {
|
|
1172
1199
|
await callback()
|
|
1173
1200
|
return
|
|
1174
1201
|
}
|
|
1175
1202
|
|
|
1176
|
-
currentFrame.push(callback)
|
|
1203
|
+
currentFrame.afterCommitCallbacks.push(callback)
|
|
1177
1204
|
}
|
|
1178
1205
|
|
|
1179
1206
|
/**
|
|
@@ -1240,23 +1267,34 @@ export default class VelociousDatabaseDriversBase {
|
|
|
1240
1267
|
await this.query("COMMIT", options)
|
|
1241
1268
|
}
|
|
1242
1269
|
|
|
1270
|
+
/**
|
|
1271
|
+
* Runs every guard registered to the transaction frame.
|
|
1272
|
+
* @param {TransactionCallbackFrame} callbackFrame - Frame whose guards are completing.
|
|
1273
|
+
* @returns {Promise<void>} - Resolves when every guard accepts the commit.
|
|
1274
|
+
*/
|
|
1275
|
+
async _runBeforeCommitCallbacks(callbackFrame) {
|
|
1276
|
+
for (const callback of callbackFrame.beforeCommitCallbacks) {
|
|
1277
|
+
await callback()
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1243
1281
|
/**
|
|
1244
1282
|
* Merges committed callbacks into the parent transaction frame or runs them when the outermost commit completes.
|
|
1245
1283
|
* @returns {Promise<void>} - Resolves when complete.
|
|
1246
1284
|
*/
|
|
1247
|
-
async
|
|
1248
|
-
const
|
|
1285
|
+
async _commitTransactionCallbackFrame() {
|
|
1286
|
+
const committedFrame = this._transactionCallbackFrames.pop()
|
|
1249
1287
|
|
|
1250
|
-
if (!
|
|
1288
|
+
if (!committedFrame || committedFrame.afterCommitCallbacks.length === 0) return
|
|
1251
1289
|
|
|
1252
|
-
const parentFrame = this.
|
|
1290
|
+
const parentFrame = this._transactionCallbackFrames[this._transactionCallbackFrames.length - 1]
|
|
1253
1291
|
|
|
1254
1292
|
if (parentFrame) {
|
|
1255
|
-
parentFrame.push(...
|
|
1293
|
+
parentFrame.afterCommitCallbacks.push(...committedFrame.afterCommitCallbacks)
|
|
1256
1294
|
return
|
|
1257
1295
|
}
|
|
1258
1296
|
|
|
1259
|
-
for (const callback of
|
|
1297
|
+
for (const callback of committedFrame.afterCommitCallbacks) {
|
|
1260
1298
|
await callback()
|
|
1261
1299
|
}
|
|
1262
1300
|
}
|
|
@@ -87,6 +87,17 @@ export default class VelociousDatabaseOperationConnection {
|
|
|
87
87
|
return /** @type {Promise<T>} */ (this._physicalConnection.transaction(callback, {operationOwner: this._owner}))
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Registers an operation-owned before-commit guard.
|
|
92
|
+
* @param {() => void | Promise<void>} callback - Guard callback.
|
|
93
|
+
* @returns {Promise<void>} - Resolves after registration.
|
|
94
|
+
*/
|
|
95
|
+
async beforeCommit(callback) {
|
|
96
|
+
this._operation.assertActive()
|
|
97
|
+
|
|
98
|
+
await this._physicalConnection.beforeCommit(callback, {operationOwner: this._owner})
|
|
99
|
+
}
|
|
100
|
+
|
|
90
101
|
/**
|
|
91
102
|
* Registers an operation-owned after-commit callback.
|
|
92
103
|
* @param {() => void | Promise<void>} callback - Callback.
|
|
@@ -85,6 +85,16 @@ export default class VelociousDatabaseOperation {
|
|
|
85
85
|
await this.connection().afterCommit(callback)
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Registers a guard owned by the current transaction/savepoint frame.
|
|
90
|
+
* @param {(context: {operation: VelociousDatabaseOperation}) => void | Promise<void>} callback - Guard callback.
|
|
91
|
+
* @returns {Promise<void>} - Resolves after registration.
|
|
92
|
+
*/
|
|
93
|
+
async beforeCommit(callback) {
|
|
94
|
+
this.assertActive()
|
|
95
|
+
await this.connection().beforeCommit(() => callback({operation: this}))
|
|
96
|
+
}
|
|
97
|
+
|
|
88
98
|
/**
|
|
89
99
|
* Runs a nested operation transaction/savepoint.
|
|
90
100
|
* @template T
|
|
@@ -8,6 +8,7 @@ import EventEmitter from "../../utils/event-emitter.js"
|
|
|
8
8
|
import Logger from "../../logger.js"
|
|
9
9
|
import Request from "./request.js"
|
|
10
10
|
import RequestRunner from "./request-runner.js"
|
|
11
|
+
import {applyResponseCompression} from "./response-compression.js"
|
|
11
12
|
import WebsocketSession from "./websocket-session.js"
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -38,6 +39,16 @@ export default class VeoliciousHttpServerClient {
|
|
|
38
39
|
events = new EventEmitter()
|
|
39
40
|
state = "initial"
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Whether a done-requests drain is currently sending responses for this client.
|
|
44
|
+
* @type {boolean} */
|
|
45
|
+
_doneRequestsDrainActive = false
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether another drain was requested while one was already active.
|
|
49
|
+
* @type {boolean} */
|
|
50
|
+
_doneRequestsDrainPending = false
|
|
51
|
+
|
|
41
52
|
/**
|
|
42
53
|
* Runs constructor.
|
|
43
54
|
* @param {object} args - Options object.
|
|
@@ -311,12 +322,38 @@ export default class VeoliciousHttpServerClient {
|
|
|
311
322
|
|
|
312
323
|
requestDone = () => {
|
|
313
324
|
this.logger.debug(() => ["requestDone", {clientCount: this.clientCount, queueLength: this.requestRunners.length}])
|
|
314
|
-
|
|
325
|
+
|
|
326
|
+
return this._drainDoneRequests().catch((error) => {
|
|
315
327
|
this.logger.warn("Failed while sending done requests", error)
|
|
316
328
|
this.events.emit("close")
|
|
317
329
|
})
|
|
318
330
|
}
|
|
319
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Drains done requests one at a time. A runner is shifted out of the queue before
|
|
334
|
+
* its response finishes sending (async compression, file transfer), so an
|
|
335
|
+
* overlapping drain would otherwise pick up the next runner and reorder pipelined
|
|
336
|
+
* socket writes. Calls that arrive while a drain is active are folded into it.
|
|
337
|
+
* @returns {Promise<void>} - Resolves when every done response has been sent.
|
|
338
|
+
*/
|
|
339
|
+
async _drainDoneRequests() {
|
|
340
|
+
if (this._doneRequestsDrainActive) {
|
|
341
|
+
this._doneRequestsDrainPending = true
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
this._doneRequestsDrainActive = true
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
do {
|
|
349
|
+
this._doneRequestsDrainPending = false
|
|
350
|
+
await this.sendDoneRequests()
|
|
351
|
+
} while (this._doneRequestsDrainPending)
|
|
352
|
+
} finally {
|
|
353
|
+
this._doneRequestsDrainActive = false
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
320
357
|
async sendDoneRequests() {
|
|
321
358
|
while (true) {
|
|
322
359
|
const requestRunner = this.requestRunners[0]
|
|
@@ -393,6 +430,14 @@ export default class VeoliciousHttpServerClient {
|
|
|
393
430
|
// arrive — drop the body entirely for those codes.
|
|
394
431
|
const isBodylessStatus = isNoBodyStatusCode(response.getStatusCode())
|
|
395
432
|
|
|
433
|
+
// HEAD responses select and compute the exact same representation headers as the
|
|
434
|
+
// equivalent GET (including Content-Length and any negotiated Content-Encoding),
|
|
435
|
+
// but no buffered or file body is emitted below.
|
|
436
|
+
const isHeadRequest = request.httpMethod() == "HEAD"
|
|
437
|
+
|
|
438
|
+
/** @type {string | Uint8Array | null} */
|
|
439
|
+
let bodyToEmit = body
|
|
440
|
+
|
|
396
441
|
if (!isBodylessStatus) {
|
|
397
442
|
let contentLength
|
|
398
443
|
|
|
@@ -400,9 +445,34 @@ export default class VeoliciousHttpServerClient {
|
|
|
400
445
|
const stats = await fs.stat(filePath)
|
|
401
446
|
contentLength = stats.size
|
|
402
447
|
} else {
|
|
403
|
-
|
|
448
|
+
// String bodies are UTF-8 framed, so the buffered bytes are the UTF-8 encoding;
|
|
449
|
+
// Uint8Array bodies are already the exact wire bytes.
|
|
450
|
+
const bodyBuffer = bodyIsString ? Buffer.from(body, "utf8") : Buffer.from(body)
|
|
451
|
+
const compressionResult = await applyResponseCompression({
|
|
452
|
+
bodyBuffer,
|
|
453
|
+
compression: this.configuration.getHttpServerCompression(),
|
|
454
|
+
request,
|
|
455
|
+
response
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
if (compressionResult.outcome == "not-acceptable") {
|
|
459
|
+
// The client forbids identity and no supported coding is acceptable: answer
|
|
460
|
+
// with an empty 406 instead of an unacceptable representation.
|
|
461
|
+
response.setStatus(406)
|
|
462
|
+
response.setBody("")
|
|
463
|
+
bodyToEmit = ""
|
|
464
|
+
contentLength = 0
|
|
465
|
+
} else if (compressionResult.outcome == "compressed") {
|
|
466
|
+
bodyToEmit = compressionResult.body
|
|
467
|
+
contentLength = compressionResult.body.length
|
|
468
|
+
} else {
|
|
469
|
+
contentLength = bodyBuffer.length
|
|
470
|
+
}
|
|
404
471
|
}
|
|
405
472
|
|
|
473
|
+
// Remove any application pre-set Content-Length (any casing) so exactly one
|
|
474
|
+
// recomputed value goes on the wire.
|
|
475
|
+
response.removeHeader("Content-Length")
|
|
406
476
|
response.setHeader("Content-Length", contentLength)
|
|
407
477
|
}
|
|
408
478
|
|
|
@@ -427,11 +497,14 @@ export default class VeoliciousHttpServerClient {
|
|
|
427
497
|
if (isBodylessStatus) {
|
|
428
498
|
this.logger.debug(() => ["sendResponse body suppressed for no-body status", {clientCount: this.clientCount, statusCode: response.getStatusCode()}])
|
|
429
499
|
if (hasFilePath) await this.sendFileOutput(filePath, false, fileOnFinished)
|
|
500
|
+
} else if (isHeadRequest) {
|
|
501
|
+
this.logger.debug(() => ["sendResponse body suppressed for HEAD request", {clientCount: this.clientCount}])
|
|
502
|
+
if (hasFilePath) await this.sendFileOutput(filePath, false, fileOnFinished)
|
|
430
503
|
} else if (hasFilePath) {
|
|
431
504
|
await this.sendFileOutput(filePath, true, fileOnFinished)
|
|
432
505
|
} else {
|
|
433
|
-
this.events.emit("output",
|
|
434
|
-
this.logger.debug(() => ["sendResponse body emitted", {clientCount: this.clientCount, bodyLength:
|
|
506
|
+
this.events.emit("output", bodyToEmit)
|
|
507
|
+
this.logger.debug(() => ["sendResponse body emitted", {clientCount: this.clientCount, bodyLength: bodyToEmit ? bodyToEmit.length : 0}])
|
|
435
508
|
}
|
|
436
509
|
|
|
437
510
|
await requestRunner.logCompletedRequest()
|