velocious 1.0.570 → 1.0.572

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 (108) hide show
  1. package/README.md +46 -0
  2. package/build/background-jobs/web/authorization.js +2 -33
  3. package/build/background-jobs/web/path-matcher.js +5 -30
  4. package/build/configuration-types.js +17 -0
  5. package/build/configuration.js +70 -0
  6. package/build/database/drivers/base.js +17 -0
  7. package/build/database/drivers/mssql/index.js +30 -0
  8. package/build/database/record/index.js +22 -10
  9. package/build/deployment-api/controller.js +437 -0
  10. package/build/deployment-api/index.js +210 -0
  11. package/build/deployment-api/path-matcher.js +45 -0
  12. package/build/deployment-api/registry.js +84 -0
  13. package/build/deployment-api/run-store.js +798 -0
  14. package/build/deployment-api/sanitize.js +114 -0
  15. package/build/http-server/client/index.js +77 -4
  16. package/build/http-server/client/response-compression.js +224 -0
  17. package/build/http-server/client/response.js +57 -0
  18. package/build/http-server/websocket-events-host.js +91 -48
  19. package/build/src/background-jobs/web/authorization.d.ts.map +1 -1
  20. package/build/src/background-jobs/web/authorization.js +3 -29
  21. package/build/src/background-jobs/web/path-matcher.d.ts +2 -12
  22. package/build/src/background-jobs/web/path-matcher.d.ts.map +1 -1
  23. package/build/src/background-jobs/web/path-matcher.js +5 -30
  24. package/build/src/configuration-types.d.ts +55 -0
  25. package/build/src/configuration-types.d.ts.map +1 -1
  26. package/build/src/configuration-types.js +16 -1
  27. package/build/src/configuration.d.ts +6 -0
  28. package/build/src/configuration.d.ts.map +1 -1
  29. package/build/src/configuration.js +61 -1
  30. package/build/src/database/drivers/base.d.ts +16 -0
  31. package/build/src/database/drivers/base.d.ts.map +1 -1
  32. package/build/src/database/drivers/base.js +16 -1
  33. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  34. package/build/src/database/drivers/mssql/index.js +29 -1
  35. package/build/src/database/record/index.d.ts +3 -2
  36. package/build/src/database/record/index.d.ts.map +1 -1
  37. package/build/src/database/record/index.js +21 -10
  38. package/build/src/deployment-api/controller.d.ts +117 -0
  39. package/build/src/deployment-api/controller.d.ts.map +1 -0
  40. package/build/src/deployment-api/controller.js +384 -0
  41. package/build/src/deployment-api/index.d.ts +46 -0
  42. package/build/src/deployment-api/index.d.ts.map +1 -0
  43. package/build/src/deployment-api/index.js +178 -0
  44. package/build/src/deployment-api/path-matcher.d.ts +31 -0
  45. package/build/src/deployment-api/path-matcher.d.ts.map +1 -0
  46. package/build/src/deployment-api/path-matcher.js +39 -0
  47. package/build/src/deployment-api/registry.d.ts +106 -0
  48. package/build/src/deployment-api/registry.d.ts.map +1 -0
  49. package/build/src/deployment-api/registry.js +74 -0
  50. package/build/src/deployment-api/run-store.d.ts +402 -0
  51. package/build/src/deployment-api/run-store.d.ts.map +1 -0
  52. package/build/src/deployment-api/run-store.js +711 -0
  53. package/build/src/deployment-api/sanitize.d.ts +27 -0
  54. package/build/src/deployment-api/sanitize.d.ts.map +1 -0
  55. package/build/src/deployment-api/sanitize.js +100 -0
  56. package/build/src/http-server/client/index.d.ts +17 -1
  57. package/build/src/http-server/client/index.d.ts.map +1 -1
  58. package/build/src/http-server/client/index.js +74 -5
  59. package/build/src/http-server/client/response-compression.d.ts +71 -0
  60. package/build/src/http-server/client/response-compression.d.ts.map +1 -0
  61. package/build/src/http-server/client/response-compression.js +197 -0
  62. package/build/src/http-server/client/response.d.ts +27 -0
  63. package/build/src/http-server/client/response.d.ts.map +1 -1
  64. package/build/src/http-server/client/response.js +49 -1
  65. package/build/src/http-server/websocket-events-host.d.ts +28 -11
  66. package/build/src/http-server/websocket-events-host.d.ts.map +1 -1
  67. package/build/src/http-server/websocket-events-host.js +84 -46
  68. package/build/src/sync/signed-sync-envelope-replay-service.d.ts +154 -0
  69. package/build/src/sync/signed-sync-envelope-replay-service.d.ts.map +1 -0
  70. package/build/src/sync/signed-sync-envelope-replay-service.js +294 -0
  71. package/build/src/sync/sync-envelope-replay-service.d.ts +84 -15
  72. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  73. package/build/src/sync/sync-envelope-replay-service.js +98 -16
  74. package/build/src/utils/bearer-token.d.ts +15 -0
  75. package/build/src/utils/bearer-token.d.ts.map +1 -0
  76. package/build/src/utils/bearer-token.js +29 -0
  77. package/build/src/utils/mount-prefix.d.ts +21 -0
  78. package/build/src/utils/mount-prefix.d.ts.map +1 -0
  79. package/build/src/utils/mount-prefix.js +35 -0
  80. package/build/sync/signed-sync-envelope-replay-service.js +342 -0
  81. package/build/sync/sync-envelope-replay-service.js +110 -15
  82. package/build/tsconfig.tsbuildinfo +1 -1
  83. package/build/utils/bearer-token.js +34 -0
  84. package/build/utils/mount-prefix.js +36 -0
  85. package/package.json +4 -3
  86. package/scripts/docker-run.sh +20 -0
  87. package/scripts/verify-docker-dev-environment.js +579 -0
  88. package/src/background-jobs/web/authorization.js +2 -33
  89. package/src/background-jobs/web/path-matcher.js +5 -30
  90. package/src/configuration-types.js +17 -0
  91. package/src/configuration.js +70 -0
  92. package/src/database/drivers/base.js +17 -0
  93. package/src/database/drivers/mssql/index.js +30 -0
  94. package/src/database/record/index.js +22 -10
  95. package/src/deployment-api/controller.js +437 -0
  96. package/src/deployment-api/index.js +210 -0
  97. package/src/deployment-api/path-matcher.js +45 -0
  98. package/src/deployment-api/registry.js +84 -0
  99. package/src/deployment-api/run-store.js +798 -0
  100. package/src/deployment-api/sanitize.js +114 -0
  101. package/src/http-server/client/index.js +77 -4
  102. package/src/http-server/client/response-compression.js +224 -0
  103. package/src/http-server/client/response.js +57 -0
  104. package/src/http-server/websocket-events-host.js +91 -48
  105. package/src/sync/signed-sync-envelope-replay-service.js +342 -0
  106. package/src/sync/sync-envelope-replay-service.js +110 -15
  107. package/src/utils/bearer-token.js +34 -0
  108. package/src/utils/mount-prefix.js +36 -0
package/README.md CHANGED
@@ -15,6 +15,7 @@
15
15
  * Declarative client sync scopes with per-scope cursors, automatic mutation tracking, realtime delivery, and `sync`/`pull` progress reporting for "X of Y" import screens (see [docs/sync-client.md](docs/sync-client.md))
16
16
  * Reactive `useLiveQuery(Model.where(...))` queries that stay current from committed local model changes across local writes, pulls, and realtime (see [docs/live-queries.md](docs/live-queries.md))
17
17
  * Server-side sync envelope replay orchestration for app-owned sync receivers (see [docs/sync-envelope-replay-service.md](docs/sync-envelope-replay-service.md))
18
+ * AwesomeTasks-shaped offline sync proof using routed resources, domain commands, signed offline grants, and peer-forwarded mutations (see [docs/awesome-tasks-offline-sync-proof.md](docs/awesome-tasks-offline-sync-proof.md))
18
19
  * SQLite web persistence that automatically prefers OPFS, then IndexedDB, and migrates legacy persisted bytes when possible (see [docs/sqlite-web-persistence.md](docs/sqlite-web-persistence.md))
19
20
  * Expo / Metro compatibility guidance and a real Expo export check (see [docs/expo-metro-compatibility.md](docs/expo-metro-compatibility.md))
20
21
  * Gap-less positional lists with automatic reordering via `actsAsList`, including models with numeric, string, or UUID primary keys (see [docs/acts-as-list.md](docs/acts-as-list.md))
@@ -26,8 +27,10 @@
26
27
  * Translated model attributes with current-locale relationship sorting (see [docs/translations.md](docs/translations.md))
27
28
  * Cross-process broadcast bus for `broadcastToChannel` via `velocious beacon`, including background job runner processes (see [docs/beacon.md](docs/beacon.md))
28
29
  * Configurable HTTP server worker handlers plus backpressured, descriptor-only file responses with completion callbacks (see [docs/http-server.md](docs/http-server.md))
30
+ * 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
31
  * 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
32
  * Durable one-off background-job scheduling with exact epoch timestamps (see [docs/scheduled-background-job-enqueue.md](docs/scheduled-background-job-enqueue.md))
33
+ * Mountable authenticated callable deployment API with allowlisted projects/stages, immutable-revision validation, idempotent runs, duplicate-safe interruption reconciliation, and adapter-owned execution for integrations like Rampway (see [docs/deployment-api.md](docs/deployment-api.md))
31
34
  * Rails-style request and database query logging (see [docs/logging.md](docs/logging.md))
32
35
  * EJS-backed mailers with delivery, queueing, and payload rendering support (see [docs/mailers.md](docs/mailers.md))
33
36
  * Trusted reverse proxy handling for `request.remoteAddress()` (see [docs/trusted-proxies.md](docs/trusted-proxies.md))
@@ -93,6 +96,45 @@ npm run test:expo
93
96
 
94
97
  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.
95
98
 
99
+ # Docker development environment
100
+
101
+ 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.
102
+
103
+ Prerequisites: Docker with the Compose v2 plugin, and this repository checked out at `$DEV_HOME_PATH/velocious` (default `DEV_HOME_PATH`: `/home/dev`).
104
+
105
+ 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:
106
+
107
+ ```bash
108
+ cp .env.example .env
109
+ ```
110
+
111
+ `$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.
112
+
113
+ Normal usage:
114
+
115
+ ```bash
116
+ docker compose up --build --detach dev
117
+ docker compose exec dev bash
118
+ scripts/docker-run.sh npm ci # one-off command in a disposable container
119
+ ```
120
+
121
+ 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.
122
+
123
+ Concurrent isolated instances use the standard Compose project-name contract plus a distinct development home per instance:
124
+
125
+ ```bash
126
+ COMPOSE_PROJECT_NAME=velocious-review DEV_HOME_PATH=/srv/dev-homes/review \
127
+ docker compose up --build --detach dev
128
+ ```
129
+
130
+ 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.
131
+
132
+ After changing the Docker artifacts, run the checked-in static contract verifier:
133
+
134
+ ```bash
135
+ npm run verify:docker-dev-environment
136
+ ```
137
+
96
138
  # Code quality (fallow)
97
139
 
98
140
  [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.
@@ -1831,6 +1873,8 @@ this.getConfiguration().getWebsocketEvents().publish(channel, payload)
1831
1873
  this.renderJsonArg({status: "published"})
1832
1874
  ```
1833
1875
 
1876
+ 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.
1877
+
1834
1878
  ## Websocket channels
1835
1879
 
1836
1880
  You can resolve websocket channel classes from subscribe messages and let them decide which streams to allow:
@@ -2365,6 +2409,8 @@ When the server runs in the `development` environment, Velocious watches applica
2365
2409
 
2366
2410
  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).
2367
2411
 
2412
+ 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).
2413
+
2368
2414
  # Authorization (CanCan-style)
2369
2415
 
2370
2416
  Define resource classes with an `abilities()` method and use `can` / `cannot` rules to constrain model access.
@@ -1,37 +1,6 @@
1
1
  // @ts-check
2
2
 
3
- import crypto from "node:crypto"
4
-
5
- /**
6
- * Constant-time comparison so token checks don't leak length/contents through
7
- * timing. Returns false for differing lengths before the timing-safe compare.
8
- * @param {string} a - First value.
9
- * @param {string} b - Second value.
10
- * @returns {boolean} - Whether the values are equal.
11
- */
12
- function safeEqual(a, b) {
13
- const bufferA = Buffer.from(String(a))
14
- const bufferB = Buffer.from(String(b))
15
-
16
- if (bufferA.length !== bufferB.length) return false
17
-
18
- return crypto.timingSafeEqual(bufferA, bufferB)
19
- }
20
-
21
- /**
22
- * Runs bearer token.
23
- * @param {import("../../http-server/client/request.js").default | import("../../http-server/client/websocket-request.js").default} request - Request object.
24
- * @returns {string | null} - Bearer token from the Authorization header, if any.
25
- */
26
- function bearerToken(request) {
27
- const header = request.header("authorization")
28
-
29
- if (typeof header !== "string") return null
30
-
31
- const match = header.match(/^Bearer\s+(.+)$/i)
32
-
33
- return match ? match[1].trim() : null
34
- }
3
+ import {bearerToken, constantTimeEqual} from "../../utils/bearer-token.js"
35
4
 
36
5
  /**
37
6
  * Runs is loopback.
@@ -72,7 +41,7 @@ export async function authorizeJobsRequest({ability, configuration, options, req
72
41
 
73
42
  if (accessTokens.length > 0 && token) {
74
43
  for (const accessToken of accessTokens) {
75
- if (safeEqual(token, accessToken)) return true
44
+ if (constantTimeEqual(token, accessToken)) return true
76
45
  }
77
46
  }
78
47
 
@@ -1,28 +1,13 @@
1
1
  // @ts-check
2
2
 
3
+ import {mountSubPath, normalizeMountPrefix} from "../../utils/mount-prefix.js"
4
+
3
5
  /**
4
6
  * @typedef {object} JobsApiMatch
5
7
  * @property {string} action - Controller action to run.
6
8
  * @property {Record<string, string>} params - Extra params extracted from the path.
7
9
  */
8
-
9
- /**
10
- * Normalizes a mount prefix: ensures a leading slash and strips any trailing
11
- * slash so `/velocious/jobs/` and `/velocious/jobs` behave identically.
12
- * @param {string} at - Raw mount prefix.
13
- * @returns {string} - Normalized prefix.
14
- */
15
- export function normalizeMountPrefix(at) {
16
- if (typeof at !== "string" || !at.startsWith("/")) {
17
- throw new Error(`mount requires an 'at' path starting with '/', got: ${String(at)}`)
18
- }
19
-
20
- if (at.length > 1 && at.endsWith("/")) {
21
- return at.slice(0, -1)
22
- }
23
-
24
- return at
25
- }
10
+ export {normalizeMountPrefix}
26
11
 
27
12
  /**
28
13
  * Matches an incoming request against the read-only jobs API routes that live
@@ -35,19 +20,9 @@ export function normalizeMountPrefix(at) {
35
20
  * @returns {JobsApiMatch | null} - Matched action or null.
36
21
  */
37
22
  export function matchJobsApiPath({prefix, path, method}) {
38
- /** @type {string} */
39
- let subPath
23
+ const subPath = mountSubPath({prefix, path})
40
24
 
41
- if (prefix === "/") {
42
- // Root mount: the whole path is the sub-path (avoid building a "//" guard).
43
- subPath = path
44
- } else if (path === prefix) {
45
- subPath = "/"
46
- } else if (path.startsWith(`${prefix}/`)) {
47
- subPath = path.slice(prefix.length)
48
- } else {
49
- return null
50
- }
25
+ if (subPath === null) return null
51
26
 
52
27
  if (method === "GET" && subPath === "/api/health") return {action: "health", params: {}}
53
28
  if (method === "GET" && subPath === "/api/stats") return {action: "stats", params: {}}
@@ -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.
@@ -961,6 +961,23 @@ export default class VelociousDatabaseDriversBase {
961
961
  */
962
962
  supportsDefaultPrimaryKeyUUID() { return false }
963
963
 
964
+ /**
965
+ * Executes an insert that carries an explicit primary-key value
966
+ * (client-generated offline-sync ids). Drivers whose auto-increment columns
967
+ * reject explicit values (MSSQL IDENTITY) override this to run the insert
968
+ * with identity insert enabled in a single request.
969
+ * @param {object} args - Options object.
970
+ * @param {QueryOptions} args.options - Query options for the standard query path.
971
+ * @param {string} args.sql - Generated insert SQL.
972
+ * @param {string} args.tableName - Table being inserted into.
973
+ * @returns {Promise<QueryResultType>} - Insert result.
974
+ */
975
+ async insertWithExplicitPrimaryKey({options, sql, tableName}) {
976
+ void tableName
977
+
978
+ return await this.query(sql, options)
979
+ }
980
+
964
981
  /**
965
982
  * Runs supports insert into returning.
966
983
  * @abstract
@@ -362,6 +362,36 @@ export default class VelociousDatabaseDriversMssql extends Base{
362
362
  shouldSetAutoIncrementWhenPrimaryKey() { return true }
363
363
  supportsDefaultPrimaryKeyUUID() { return true }
364
364
 
365
+ /**
366
+ * Runs an explicit primary-key insert as one batch request: SQL Server scopes
367
+ * IDENTITY_INSERT to the session, and node-mssql pool-backed requests may use
368
+ * a different physical session per query, so enabling it in a separate query
369
+ * can leave the actual INSERT on another session. A single batch keeps the
370
+ * whole sequence on one session by construction: enable, insert, disable on
371
+ * success, and a CATCH that disables and rethrows the original error.
372
+ * @param {object} args - Options object.
373
+ * @param {import("../base.js").QueryOptions} args.options - Query options for the standard query path.
374
+ * @param {string} args.sql - Generated insert SQL.
375
+ * @param {string} args.tableName - Table being inserted into.
376
+ * @returns {Promise<import("../base.js").QueryResultType>} - Insert result.
377
+ */
378
+ async insertWithExplicitPrimaryKey({options, sql, tableName}) {
379
+ const quotedTable = this.quoteTable(tableName)
380
+ const batch = [
381
+ `SET IDENTITY_INSERT ${quotedTable} ON;`,
382
+ "BEGIN TRY",
383
+ `${sql};`,
384
+ `SET IDENTITY_INSERT ${quotedTable} OFF;`,
385
+ "END TRY",
386
+ "BEGIN CATCH",
387
+ `SET IDENTITY_INSERT ${quotedTable} OFF;`,
388
+ "THROW;",
389
+ "END CATCH"
390
+ ].join("\n")
391
+
392
+ return await this.query(batch, options)
393
+ }
394
+
365
395
  /**
366
396
  * Runs escape.
367
397
  * @param {?} value - Value to use.
@@ -4248,15 +4248,21 @@ class VelociousDatabaseRecord {
4248
4248
  * @returns {Promise<void>} - Resolves when complete.
4249
4249
  */
4250
4250
  async _createNewRecord() {
4251
- if (!this.getModelClass().connection()["insertSql"]) {
4252
- throw new Error(`No insertSql on ${this.getModelClass().connection().constructor.name}`)
4251
+ // Resolve the connection once and pin the whole insert path to it: a pool
4252
+ // can resolve a different current connection across the awaits below, and
4253
+ // the identity-insert wrapper is only effective on the exact session that
4254
+ // ran SET IDENTITY_INSERT.
4255
+ const connection = this._connection()
4256
+
4257
+ if (!connection["insertSql"]) {
4258
+ throw new Error(`No insertSql on ${connection.constructor.name}`)
4253
4259
  }
4254
4260
 
4255
4261
  const data = Object.assign({}, this._belongsToChanges(), this.rawAttributes())
4256
4262
  const primaryKey = this.getModelClass().primaryKey()
4257
4263
  const primaryKeyColumn = this.getModelClass().getColumns().find((column) => column.getName() == primaryKey)
4258
4264
  const primaryKeyType = primaryKeyColumn?.getType()?.toLowerCase()
4259
- const driverSupportsDefaultUUID = typeof this._connection().supportsDefaultPrimaryKeyUUID == "function" && this._connection().supportsDefaultPrimaryKeyUUID()
4265
+ const driverSupportsDefaultUUID = typeof connection.supportsDefaultPrimaryKeyUUID == "function" && connection.supportsDefaultPrimaryKeyUUID()
4260
4266
  const isUUIDPrimaryKey = primaryKeyType?.includes("uuid")
4261
4267
  const shouldAssignUUIDPrimaryKey = isUUIDPrimaryKey && !driverSupportsDefaultUUID
4262
4268
  this._setDefaultTimestampValues(data)
@@ -4270,14 +4276,20 @@ class VelociousDatabaseRecord {
4270
4276
 
4271
4277
  this._normalizeDateValuesForWrite(data)
4272
4278
 
4273
- const sql = this._connection().insertSql({
4279
+ const sql = connection.insertSql({
4274
4280
  returnLastInsertedColumnNames: columnNames,
4275
4281
  tableName: this._tableName(),
4276
4282
  data
4277
4283
  })
4278
- const insertResult = await this._connection().query(sql, {logName: `${this.getModelClass().name} Create`})
4279
-
4280
- await this._applyInsertResult({data, insertResult, primaryKey})
4284
+ const insertOptions = {logName: `${this.getModelClass().name} Create`}
4285
+ // Explicit primary-key inserts into auto-increment columns go through the
4286
+ // driver's explicit-primary-key insert (MSSQL wraps it in IDENTITY_INSERT);
4287
+ // everything else uses the plain query path.
4288
+ const insertResult = hasUserProvidedPrimaryKey && primaryKeyColumn?.getAutoIncrement() === true
4289
+ ? await connection.insertWithExplicitPrimaryKey({options: insertOptions, sql, tableName: this._tableName()})
4290
+ : await connection.query(sql, insertOptions)
4291
+
4292
+ await this._applyInsertResult({connection, data, insertResult, primaryKey})
4281
4293
  this.setIsNewRecord(false)
4282
4294
 
4283
4295
  this._markLoadedRelationshipsPreloadedAfterCreate()
@@ -4303,10 +4315,10 @@ class VelociousDatabaseRecord {
4303
4315
 
4304
4316
  /**
4305
4317
  * Applies the database insert response to this record.
4306
- * @param {{data: Record<string, string | number | boolean | Date | null | undefined>, insertResult: Array<Record<string, string | number | boolean | Date | null | undefined>> | null | undefined, primaryKey: string}} options - Inserted data, connection result, and primary key column name.
4318
+ * @param {{connection: import("../drivers/base.js").default, data: Record<string, string | number | boolean | Date | null | undefined>, insertResult: Array<Record<string, string | number | boolean | Date | null | undefined>> | null | undefined, primaryKey: string}} options - Pinned insert connection, inserted data, connection result, and primary key column name.
4307
4319
  * @returns {Promise<void>} - Resolves when complete.
4308
4320
  */
4309
- async _applyInsertResult({data, insertResult, primaryKey}) {
4321
+ async _applyInsertResult({connection, data, insertResult, primaryKey}) {
4310
4322
  if (Array.isArray(insertResult) && insertResult[0] && insertResult[0][primaryKey]) {
4311
4323
  this._attributes = insertResult[0]
4312
4324
  this._changes = {}
@@ -4322,7 +4334,7 @@ class VelociousDatabaseRecord {
4322
4334
  return
4323
4335
  }
4324
4336
 
4325
- const id = await this._connection().lastInsertID()
4337
+ const id = await connection.lastInsertID()
4326
4338
 
4327
4339
  await this._reloadWithId(id)
4328
4340
  }