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.
Files changed (50) hide show
  1. package/README.md +56 -2
  2. package/build/configuration-types.js +17 -0
  3. package/build/configuration.js +70 -0
  4. package/build/database/drivers/base.js +82 -44
  5. package/build/database/operation-connection.js +11 -0
  6. package/build/database/operation.js +10 -0
  7. package/build/http-server/client/index.js +77 -4
  8. package/build/http-server/client/response-compression.js +224 -0
  9. package/build/http-server/client/response.js +57 -0
  10. package/build/http-server/websocket-events-host.js +91 -48
  11. package/build/src/configuration-types.d.ts +55 -0
  12. package/build/src/configuration-types.d.ts.map +1 -1
  13. package/build/src/configuration-types.js +16 -1
  14. package/build/src/configuration.d.ts +6 -0
  15. package/build/src/configuration.d.ts.map +1 -1
  16. package/build/src/configuration.js +61 -1
  17. package/build/src/database/drivers/base.d.ts +30 -3
  18. package/build/src/database/drivers/base.d.ts.map +1 -1
  19. package/build/src/database/drivers/base.js +78 -42
  20. package/build/src/database/operation-connection.d.ts +6 -0
  21. package/build/src/database/operation-connection.d.ts.map +1 -1
  22. package/build/src/database/operation-connection.js +10 -1
  23. package/build/src/database/operation.d.ts +8 -0
  24. package/build/src/database/operation.d.ts.map +1 -1
  25. package/build/src/database/operation.js +10 -1
  26. package/build/src/http-server/client/index.d.ts +17 -1
  27. package/build/src/http-server/client/index.d.ts.map +1 -1
  28. package/build/src/http-server/client/index.js +74 -5
  29. package/build/src/http-server/client/response-compression.d.ts +71 -0
  30. package/build/src/http-server/client/response-compression.d.ts.map +1 -0
  31. package/build/src/http-server/client/response-compression.js +197 -0
  32. package/build/src/http-server/client/response.d.ts +27 -0
  33. package/build/src/http-server/client/response.d.ts.map +1 -1
  34. package/build/src/http-server/client/response.js +49 -1
  35. package/build/src/http-server/websocket-events-host.d.ts +28 -11
  36. package/build/src/http-server/websocket-events-host.d.ts.map +1 -1
  37. package/build/src/http-server/websocket-events-host.js +84 -46
  38. package/build/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +4 -3
  40. package/scripts/docker-run.sh +20 -0
  41. package/scripts/verify-docker-dev-environment.js +579 -0
  42. package/src/configuration-types.js +17 -0
  43. package/src/configuration.js +70 -0
  44. package/src/database/drivers/base.js +82 -44
  45. package/src/database/operation-connection.js +11 -0
  46. package/src/database/operation.js +10 -0
  47. package/src/http-server/client/index.js +77 -4
  48. package/src/http-server/client/response-compression.js +224 -0
  49. package/src/http-server/client/response.js +57 -0
  50. 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 behavior, after-commit failure semantics, and migration guidance.
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.
@@ -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 {Array<Array<() => void | Promise<void>>>} */
175
- _afterCommitCallbackFrames
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._afterCommitCallbackFrames = []
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
- * Callback frame.
1073
- * @type {Array<() => void | Promise<void>>} */
1074
- const callbackFrame = []
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._afterCommitCallbackFrames.push(callbackFrame)
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._afterCommitCallbackFrames.pop()
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
- let transactionRolledBack = false
1117
-
1118
- if (savePointStarted) {
1119
- this.logger.debug("Rollback savepoint", savePointName)
1120
- try {
1121
- await this.rollbackSavePoint(savePointName, options)
1122
- } catch (savePointError) {
1123
- const message = savePointError instanceof Error ? savePointError.message : `${savePointError}`
1124
-
1125
- // MySQL sometimes drops savepoints unexpectedly; fall back to rolling back the full transaction
1126
- if (message.includes("SAVEPOINT") || message.includes("ER_SP_DOES_NOT_EXIST")) {
1127
- this.logger.debug("Savepoint rollback failed; rolling back entire transaction instead")
1128
- await this.rollbackTransaction(options)
1129
- transactionRolledBack = true
1130
- } else {
1131
- throw savePointError
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
- // Only roll back if a transaction is still open. A nested savepoint whose rollback failed
1137
- // falls back to rolling back the whole transaction (above), which already closed it and
1138
- // dropped the count to 0; rolling back again here would issue a second ROLLBACK and drive
1139
- // `_transactionsCount` below zero, which would then defeat the outermost deadlock-retry guard.
1140
- if (transactionStarted && !transactionRolledBack && this._transactionsCount > 0) {
1141
- this.logger.debug("Rollback transaction")
1142
- await this.rollbackTransaction(options)
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._commitAfterCommitCallbackFrame()
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._afterCommitCallbackFrames[this._afterCommitCallbackFrames.length - 1]
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 _commitAfterCommitCallbackFrame() {
1248
- const committedCallbacks = this._afterCommitCallbackFrames.pop()
1285
+ async _commitTransactionCallbackFrame() {
1286
+ const committedFrame = this._transactionCallbackFrames.pop()
1249
1287
 
1250
- if (!committedCallbacks || committedCallbacks.length === 0) return
1288
+ if (!committedFrame || committedFrame.afterCommitCallbacks.length === 0) return
1251
1289
 
1252
- const parentFrame = this._afterCommitCallbackFrames[this._afterCommitCallbackFrames.length - 1]
1290
+ const parentFrame = this._transactionCallbackFrames[this._transactionCallbackFrames.length - 1]
1253
1291
 
1254
1292
  if (parentFrame) {
1255
- parentFrame.push(...committedCallbacks)
1293
+ parentFrame.afterCommitCallbacks.push(...committedFrame.afterCommitCallbacks)
1256
1294
  return
1257
1295
  }
1258
1296
 
1259
- for (const callback of committedCallbacks) {
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
- void this.sendDoneRequests().catch((error) => {
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
- contentLength = bodyIsString ? Buffer.byteLength(body, "utf8") : body.byteLength
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", body)
434
- this.logger.debug(() => ["sendResponse body emitted", {clientCount: this.clientCount, bodyLength: bodyIsString ? body.length : body.byteLength}])
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()