velocious 1.0.576 → 1.0.578

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 (99) hide show
  1. package/README.md +57 -6
  2. package/build/background-jobs/main.js +15 -4
  3. package/build/background-jobs/scheduler.js +11 -7
  4. package/build/background-jobs/store.js +18 -13
  5. package/build/configuration-types.js +1 -0
  6. package/build/configuration.js +52 -10
  7. package/build/database/drivers/mysql/structure-sql.js +70 -9
  8. package/build/database/query/with-count.js +8 -8
  9. package/build/database/record/record-not-found-error.js +1 -0
  10. package/build/environment-handlers/node/cli/commands/test.js +2 -0
  11. package/build/environment-handlers/node/source-peer-package.js +172 -0
  12. package/build/frontend-model-controller.js +71 -69
  13. package/build/frontend-models/base.js +10 -1
  14. package/build/routes/resolver.js +3 -50
  15. package/build/src/background-jobs/main.d.ts.map +1 -1
  16. package/build/src/background-jobs/main.js +15 -5
  17. package/build/src/background-jobs/scheduler.d.ts +2 -2
  18. package/build/src/background-jobs/scheduler.d.ts.map +1 -1
  19. package/build/src/background-jobs/scheduler.js +11 -8
  20. package/build/src/background-jobs/store.d.ts +5 -4
  21. package/build/src/background-jobs/store.d.ts.map +1 -1
  22. package/build/src/background-jobs/store.js +17 -12
  23. package/build/src/configuration-types.d.ts +5 -0
  24. package/build/src/configuration-types.d.ts.map +1 -1
  25. package/build/src/configuration-types.js +2 -1
  26. package/build/src/configuration.d.ts +13 -1
  27. package/build/src/configuration.d.ts.map +1 -1
  28. package/build/src/configuration.js +52 -11
  29. package/build/src/database/drivers/mysql/structure-sql.d.ts +11 -0
  30. package/build/src/database/drivers/mysql/structure-sql.d.ts.map +1 -1
  31. package/build/src/database/drivers/mysql/structure-sql.js +61 -10
  32. package/build/src/database/query/with-count.js +8 -8
  33. package/build/src/database/record/record-not-found-error.d.ts +1 -0
  34. package/build/src/database/record/record-not-found-error.d.ts.map +1 -1
  35. package/build/src/database/record/record-not-found-error.js +2 -1
  36. package/build/src/environment-handlers/node/cli/commands/test.d.ts.map +1 -1
  37. package/build/src/environment-handlers/node/cli/commands/test.js +3 -1
  38. package/build/src/environment-handlers/node/source-peer-package.d.ts +46 -0
  39. package/build/src/environment-handlers/node/source-peer-package.d.ts.map +1 -0
  40. package/build/src/environment-handlers/node/source-peer-package.js +148 -0
  41. package/build/src/frontend-model-controller.d.ts +10 -10
  42. package/build/src/frontend-model-controller.d.ts.map +1 -1
  43. package/build/src/frontend-model-controller.js +73 -67
  44. package/build/src/frontend-models/base.d.ts.map +1 -1
  45. package/build/src/frontend-models/base.js +11 -2
  46. package/build/src/routes/resolver.d.ts.map +1 -1
  47. package/build/src/routes/resolver.js +4 -45
  48. package/build/src/velocious-error.d.ts +10 -0
  49. package/build/src/velocious-error.d.ts.map +1 -1
  50. package/build/src/velocious-error.js +8 -2
  51. package/build/tsconfig.tsbuildinfo +1 -1
  52. package/build/velocious-error.js +7 -1
  53. package/package.json +3 -2
  54. package/scripts/run-tests.js +61 -20
  55. package/scripts/test-browser.js +6 -1
  56. package/src/background-jobs/main.js +15 -4
  57. package/src/background-jobs/scheduler.js +11 -7
  58. package/src/background-jobs/store.js +18 -13
  59. package/src/configuration-types.js +1 -0
  60. package/src/configuration.js +52 -10
  61. package/src/database/drivers/mysql/structure-sql.js +70 -9
  62. package/src/database/query/with-count.js +8 -8
  63. package/src/database/record/record-not-found-error.js +1 -0
  64. package/src/environment-handlers/node/cli/commands/test.js +2 -0
  65. package/src/environment-handlers/node/source-peer-package.js +172 -0
  66. package/src/frontend-model-controller.js +71 -69
  67. package/src/frontend-models/base.js +10 -1
  68. package/src/routes/resolver.js +3 -50
  69. package/src/velocious-error.js +7 -1
  70. package/build/deployment-api/controller.js +0 -437
  71. package/build/deployment-api/index.js +0 -210
  72. package/build/deployment-api/path-matcher.js +0 -45
  73. package/build/deployment-api/registry.js +0 -84
  74. package/build/deployment-api/run-store.js +0 -798
  75. package/build/deployment-api/sanitize.js +0 -114
  76. package/build/src/deployment-api/controller.d.ts +0 -117
  77. package/build/src/deployment-api/controller.d.ts.map +0 -1
  78. package/build/src/deployment-api/controller.js +0 -384
  79. package/build/src/deployment-api/index.d.ts +0 -46
  80. package/build/src/deployment-api/index.d.ts.map +0 -1
  81. package/build/src/deployment-api/index.js +0 -178
  82. package/build/src/deployment-api/path-matcher.d.ts +0 -31
  83. package/build/src/deployment-api/path-matcher.d.ts.map +0 -1
  84. package/build/src/deployment-api/path-matcher.js +0 -39
  85. package/build/src/deployment-api/registry.d.ts +0 -106
  86. package/build/src/deployment-api/registry.d.ts.map +0 -1
  87. package/build/src/deployment-api/registry.js +0 -74
  88. package/build/src/deployment-api/run-store.d.ts +0 -402
  89. package/build/src/deployment-api/run-store.d.ts.map +0 -1
  90. package/build/src/deployment-api/run-store.js +0 -711
  91. package/build/src/deployment-api/sanitize.d.ts +0 -27
  92. package/build/src/deployment-api/sanitize.d.ts.map +0 -1
  93. package/build/src/deployment-api/sanitize.js +0 -100
  94. package/src/deployment-api/controller.js +0 -437
  95. package/src/deployment-api/index.js +0 -210
  96. package/src/deployment-api/path-matcher.js +0 -45
  97. package/src/deployment-api/registry.js +0 -84
  98. package/src/deployment-api/run-store.js +0 -798
  99. package/src/deployment-api/sanitize.js +0 -114
package/README.md CHANGED
@@ -9,6 +9,7 @@
9
9
  * Declarative state machines for models, with typed event methods generated into the base model (see [docs/state-machine.md](docs/state-machine.md))
10
10
  * Migrations for schema changes and UTC datetime storage (see [docs/database-migrations.md](docs/database-migrations.md))
11
11
  * External packages (engines) that contribute data models, frontend-model resources and migrations to a consuming app (see [docs/packages.md](docs/packages.md))
12
+ * Optional Rampway-owned durable deployment control plane mounted through the standard routes DSL on Velocious 1.0.577 or newer (see [docs/rampway-integration.md](docs/rampway-integration.md))
12
13
  * Controllers and views for HTTP endpoints
13
14
  * Frontend-model transport for creating, updating, querying, and subscribing to query-filtered lifecycle events over HTTP/WebSocket, with structured per-attribute validation error responses and one-budget WebSocket startup controls (see [docs/frontend-models.md](docs/frontend-models.md) and [docs/websocket-channels.md](docs/websocket-channels.md))
14
15
  * Client-side offline sync mutation logs and frontend-model optimistic queueing primitives (see the [shared-resource sync developer guide](docs/shared-resource-sync-guide.md) and [offline sync architecture](docs/offline-sync.md))
@@ -21,7 +22,7 @@
21
22
  * 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))
22
23
  * Rails-style nested-attribute writes on frontend-model `save()` (see [docs/nested-attributes.md](docs/nested-attributes.md))
23
24
  * Async-aware test-data factories with inherited traits, graph-first native association autosave, metadata-aware override precedence, callbacks, sequences, and linting (see [docs/factories.md](docs/factories.md))
24
- * Per-row association counts via `.withCount(...)`, including safe batching of structurally identical aggregates, on frontend and backend queries (see [docs/with-count.md](docs/with-count.md))
25
+ * Per-row association counts via `.withCount(...)`, including cohort-safe intersected filters and safe batching of structurally identical aggregates, on frontend and backend queries (see [docs/with-count.md](docs/with-count.md))
25
26
  * Consumer-defined per-row SQL aggregates/computations via `.queryData(...)`, with compatible projections sharing a roundtrip while preserving declared alias-overwrite order, on frontend and backend queries (see [docs/query-data.md](docs/query-data.md))
26
27
  * Per-record ability checks via `.abilities(...)` on frontend queries + `record.can(action)` (see [docs/abilities.md](docs/abilities.md))
27
28
  * Translated model attributes with current-locale relationship sorting (see [docs/translations.md](docs/translations.md))
@@ -30,7 +31,6 @@
30
31
  * 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))
31
32
  * 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))
32
33
  * 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))
34
34
  * Rails-style request and database query logging (see [docs/logging.md](docs/logging.md))
35
35
  * EJS-backed mailers with delivery, queueing, and payload rendering support (see [docs/mailers.md](docs/mailers.md))
36
36
  * Trusted reverse proxy handling for `request.remoteAddress()` (see [docs/trusted-proxies.md](docs/trusted-proxies.md))
@@ -741,7 +741,7 @@ Use `await FrontendModelBase.waitForIdle()` when a test harness or app lifecycle
741
741
 
742
742
  Frontend-model HTTP requests always use `credentials: "include"` so shared custom commands can set session cookies without app-level transport overrides.
743
743
 
744
- Unexpected frontend-model endpoint failures stay client-safe in production with `errorMessage: "Request failed."`.
744
+ Unexpected frontend-model endpoint failures stay client-safe in production with `errorType: "internal_error"`, `errorMessage: "Request failed."`, and a server-generated `correlationId` shared with the matching framework-error report. Expected application failures can use `VelociousError.safe(message, {errorType, details, code})`; generated frontend-model callers preserve the server's safe error fields. See [docs/frontend-models.md](docs/frontend-models.md#error-payloads).
745
745
  Invalid client query descriptors, such as unknown `select`, `where`, `search`, `joins`, `preload`, `group`, `sort`, `pluck`, or Ransack attributes, return the specific frontend-model query error message with `velocious.code: "frontend-model-query-error"` and are not emitted as framework errors.
746
746
  Invalid frontend-model write attributes and attachment names, including attributes rejected by `permittedParams()`, return the specific safe error message with `velocious.code: "frontend-model-attribute-error"` and are not emitted as framework errors.
747
747
  In `development` and `test`, Velocious also includes `debugErrorClass`, `debugErrorMessage`, and `debugBacktrace` fields so browser/system-test failures are easier to diagnose without exposing those details in production.
@@ -869,6 +869,51 @@ Supported route helpers:
869
869
  - `routes.get(path, {to: [ControllerClass, "action"], params?})`
870
870
  - `routes.post(path, {to: [ControllerClass, "action"], params?})`
871
871
 
872
+ ## Rampway deployment control plane
873
+
874
+ Applications can install `rampway@^0.4.0` and mount its package-owned Velocious
875
+ control plane through the existing routes DSL. Keep bearer tokens in backend
876
+ secrets and provide explicit allowlisted config paths and release branches:
877
+
878
+ ```sh
879
+ npm install rampway@^0.4.0 velocious@^1.0.577
880
+ ```
881
+
882
+ Rampway 0.4.0 declares `velocious ^1.0.574` as its peer range, but applications
883
+ mounting this API must use Velocious 1.0.577 or newer. Versions 1.0.574 through
884
+ 1.0.576 attempted an app-local controller import before the package-supplied
885
+ `controllerClass`, allowing a same-named app controller to shadow Rampway's
886
+ authenticated controller.
887
+
888
+ ```js
889
+ import RampwayDeploymentApi from "rampway/velocious"
890
+ import deploymentSecrets from "./secrets/deployments.js"
891
+
892
+ routes.draw((route) => {
893
+ route.mount(RampwayDeploymentApi, {
894
+ accessTokens: deploymentSecrets.rampwayAccessTokens,
895
+ at: "/rampway/deployments",
896
+ projects: {
897
+ "my-app": {
898
+ stages: {
899
+ production: {
900
+ configPath: "/srv/my-app/control/rampway.config.mjs",
901
+ releaseBranch: "main"
902
+ }
903
+ }
904
+ }
905
+ },
906
+ workerBootstrapPath: "/srv/my-app/control/rampway-velocious-worker.mjs"
907
+ })
908
+ })
909
+ ```
910
+
911
+ Rampway owns authentication, deployment execution, idempotency, durable runs,
912
+ audits, reconciliation, and the detached worker. Velocious supplies its normal
913
+ route, request, error-event, and database abstractions. See
914
+ [docs/rampway-integration.md](docs/rampway-integration.md) for bootstrap,
915
+ persistence, security, and rollback requirements.
916
+
872
917
 
873
918
  ```js
874
919
  import Record from "velocious/build/src/database/record/index.js"
@@ -889,7 +934,8 @@ Translated models also get a `currentTranslation` `hasOne` relationship scoped t
889
934
 
890
935
  Async class APIs initialize record metadata on first use when a model has not
891
936
  already been initialized eagerly. See [docs/model-initialization.md](docs/model-initialization.md)
892
- for the eager and lazy initialization behavior.
937
+ for the eager and lazy initialization behavior, including atomic shared bootstrap
938
+ and complete recovery after an eager initialization failure.
893
939
 
894
940
  ## Lifecycle callbacks
895
941
 
@@ -1302,7 +1348,7 @@ If you need to regenerate missing structure files without rerunning migrations,
1302
1348
  npx velocious db:schema:dump
1303
1349
  ```
1304
1350
 
1305
- `db:schema:dump` generates a structure SQL file for each configured database identifier under `db/structure-<identifier>.sql`. It only writes files when one or more expected files are missing. The generated file includes the full DDL (tables, indexes, views, triggers, etc.) followed by `INSERT INTO schema_migrations (version) VALUES (...)` for every currently applied migration version. This preserves the migration ledger in the checked-in snapshot so fresh databases loaded from it do not re-run migrations that already shaped the schemas in the file.
1351
+ `db:schema:dump` generates a structure SQL file for each configured database identifier under `db/structure-<identifier>.sql`. It only writes files when one or more expected files are missing. The generated file includes the full DDL (tables, indexes, views, triggers, etc.) followed by `INSERT INTO schema_migrations (version) VALUES (...)` for every currently applied migration version. MySQL and MariaDB dumps place same-schema referenced base tables before their dependent tables. The migration ledger preserves applied versions in the checked-in snapshot so fresh databases loaded from it do not re-run migrations that already shaped the schemas in the file.
1306
1352
 
1307
1353
  If you need to load the checked-in structure files for each configured database, use:
1308
1354
 
@@ -1812,7 +1858,7 @@ configuration.getErrorEvents().on("all-error", ({error, errorType}) => {
1812
1858
  })
1813
1859
  ```
1814
1860
 
1815
- Genuinely unexpected frontend-model command failures reach this bus too. The frontend-model controller catches them to return a client-safe `Request failed.` response, but it also emits them as `framework-error`/`all-error` (with `context.frontendModelEndpoint === true`) so they are reported instead of being silently swallowed. Expected user-flow errors are excluded: validation failures are forwarded with their real message (for example `Name can't be blank`), invalid client query descriptors are returned as frontend-model query errors, and `error.velocious`-annotated / `safeToExpose` / `errorType`-marked errors keep their expected-error status none of these reach the error bus.
1861
+ Genuinely unexpected frontend-model command failures reach this bus too. The frontend-model controller catches them to return a client-safe `internal_error` response with `Request failed.` and a correlation ID, then emits them as `framework-error`/`all-error` with the same correlation ID and `context.frontendModelEndpoint === true`. Expected user-flow errors are excluded: validation failures are forwarded with their real message (for example `Name can't be blank`), invalid client query descriptors are returned as frontend-model query errors, and `error.velocious`-annotated / `safeToExpose` errors keep their expected-error status. A raw `errorType` property alone is not considered safe and does not suppress reporting.
1816
1862
 
1817
1863
  Unexpected inbound decoded WebSocket dispatch failures emit one `framework-error` and one matching `all-error`. Established expected client-flow errors remain excluded from both events.
1818
1864
 
@@ -2181,6 +2227,11 @@ VELOCIOUS_BACKGROUND_JOBS_JOB_TIMEOUT_MS=5400000
2181
2227
 
2182
2228
  New jobs default to `executionMode: "pooled"`: a worker runs them in warm, reusable Node child runners. `pooledRunnerCount` (default: `4`) bounds this independent per-worker pool, and `pooledRunnerConcurrency` (default: `1`) sets how many jobs each child runs at once on its own event loop, so total pooled capacity is `pooledRunnerCount × pooledRunnerConcurrency` — raise concurrency for I/O-bound jobs to get high throughput from a bounded, isolated set of processes. `pooledRunnerCount`, `pooledRunnerConcurrency`, and `pooledRunnerMaxJobs` must be finite positive integers; the RSS and lifetime limits must be finite positive numbers. A child is recycled after an acknowledged job when it reaches `pooledRunnerMaxJobs` (default: `100`), `pooledRunnerMaxRssBytes` (default: `536870912`, or 512 MiB), or `pooledRunnerMaxLifetimeMs` (default: `3600000`, or one hour). `execution_mode` is the single source of truth for a job's runtime — pooled rows persist as `execution_mode = "pooled"` directly. See [execution modes and pooled runners](docs/background-jobs.md#execution-modes-and-pooled-runners).
2183
2229
 
2230
+ Cold pooled jobs share one atomic model-bootstrap phase. If that phase fails, all
2231
+ waiting jobs receive the failure; a later job in the surviving child cannot run
2232
+ until a complete model-initialization phase succeeds. The pool's configured
2233
+ concurrency and per-job connection scopes are unchanged.
2234
+
2184
2235
  `maxConcurrentForkedJobs` (default: `4`) caps how many out-of-process `executionMode: "forked"` or `executionMode: "spawned"` jobs one worker may keep in flight. Forked jobs use `child_process.fork()` with an attached IPC channel. After the main process acknowledges their durable status report, forked and spawned one-shot runners exit without waiting for graceful Beacon/database teardown; the OS closes their process-owned resources. A missing or rejected status acknowledgement makes the runner exit as failed instead of reporting clean success. Spawned jobs use the legacy `background-jobs-runner` CLI process via `child_process.spawn()` and are only for callers that intentionally want that spawned behavior.
2185
2236
 
2186
2237
  `jobTimeoutMs` (or `VELOCIOUS_BACKGROUND_JOBS_JOB_TIMEOUT_MS`, milliseconds; default: disabled) is a wall-clock backstop for `"forked"` and `"pooled"` jobs. A job still running after the timeout is terminated (`SIGTERM`, then `SIGKILL` after the reaping grace) and reported `failed`, so a genuinely-hung job can't pin a worker's capacity — and its whole-app boot and DB connections — indefinitely (notably a retired-release worker draining after a deploy). For a **pooled** job the whole child running it is killed, so its concurrent in-flight siblings on that child are also reported `failed` and requeued — a hung JS job can't be cancelled any other way — before a replacement child is spawned. It's a coarse safety net, not per-job tuning: it applies to every forked and pooled job, so set it well above the longest legitimate job. Omit it, or set `null`/`<= 0`, to disable. `"inline"` jobs are not covered — they share the worker's process and can't be killed without killing the worker. See [docs/background-jobs.md](docs/background-jobs.md#job-timeout-hung-runner-backstop).
@@ -786,9 +786,9 @@ export default class BackgroundJobsMain {
786
786
  options: message.options || {}
787
787
  })
788
788
 
789
- jsonSocket.send({type: "schedule-replaced", ...result})
790
789
  this._notifyEnqueued()
791
790
  await this._drain()
791
+ jsonSocket.send({type: "schedule-replaced", ...result})
792
792
  } catch (error) {
793
793
  this._handleClientMutationError({
794
794
  context: {jobName: message.jobName, scheduleKey: message.scheduleKey, stage: "background-job-replace-scheduled"},
@@ -812,9 +812,9 @@ export default class BackgroundJobsMain {
812
812
  try {
813
813
  const result = await this.store.cancelScheduled(message.scheduleKey)
814
814
 
815
- jsonSocket.send({type: "schedule-cancelled", ...result})
816
815
  this._notifyEnqueued()
817
816
  await this._drain()
817
+ jsonSocket.send({type: "schedule-cancelled", ...result})
818
818
  } catch (error) {
819
819
  this._handleClientMutationError({
820
820
  context: {scheduleKey: message.scheduleKey, stage: "background-job-cancel-scheduled"},
@@ -1354,9 +1354,20 @@ export default class BackgroundJobsMain {
1354
1354
  if (this.dispatchStrategy === "polling") return
1355
1355
 
1356
1356
  const next = await this.store.nextScheduledJob()
1357
- if (!next || typeof next.scheduledAtMs !== "number") return
1357
+ let delay
1358
+
1359
+ if (next && typeof next.scheduledAtMs === "number") {
1360
+ delay = Math.max(0, Math.min(next.scheduledAtMs - Date.now(), MAX_TIMER_MS))
1361
+ }
1362
+
1363
+ // `nextScheduledJob` only returns future jobs, so a job that became
1364
+ // eligible after the drain's eligible-job probe is invisible to it. If one
1365
+ // is dispatchable now, arm a 0-delay re-drain so it is dispatched
1366
+ // immediately instead of being stranded until the next future timer (or
1367
+ // external signal) fires.
1368
+ if (await this.nextAvailableJobForReadyWorkers()) delay = 0
1358
1369
 
1359
- const delay = Math.max(0, Math.min(next.scheduledAtMs - Date.now(), MAX_TIMER_MS))
1370
+ if (typeof delay !== "number") return
1360
1371
 
1361
1372
  this._scheduledTimer = setTimeout(() => {
1362
1373
  this._scheduledTimer = undefined
@@ -81,8 +81,8 @@ export default class BackgroundJobsScheduler {
81
81
  * Narrows the runtime value to the documented type.
82
82
  * @type {Array<ReturnType<typeof setTimeout>>} */
83
83
  this.timeoutIds = []
84
- /** @type {Set<Promise<void>>} - Scheduled enqueues that shutdown must drain. */
85
- this.pendingEnqueues = new Set()
84
+ /** @type {Map<string, Promise<void>>} - In-flight scheduled enqueues by schedule key that shutdown must drain. */
85
+ this.pendingEnqueuesByJobKey = new Map()
86
86
  /**
87
87
  * Narrows the runtime value to the documented type.
88
88
  * @type {boolean} - True between stop() and the next start(); cron self-rescheduler checks this so a stop() during an in-flight enqueue doesn't immediately re-arm.
@@ -131,7 +131,7 @@ export default class BackgroundJobsScheduler {
131
131
  this.intervalIds = []
132
132
  this.timeoutIds = []
133
133
 
134
- await Promise.all(this.pendingEnqueues)
134
+ await Promise.all(this.pendingEnqueuesByJobKey.values())
135
135
  }
136
136
 
137
137
  /**
@@ -181,13 +181,15 @@ export default class BackgroundJobsScheduler {
181
181
  }
182
182
 
183
183
  const timeoutId = setTimeout(() => {
184
- void this.runScheduledJob({jobConfiguration, jobKey})
184
+ const scheduledEnqueue = this.runScheduledJob({jobConfiguration, jobKey})
185
185
 
186
186
  const intervalId = setInterval(() => {
187
- void this.runScheduledJob({jobConfiguration, jobKey})
187
+ return this.runScheduledJob({jobConfiguration, jobKey})
188
188
  }, intervalMs)
189
189
 
190
190
  this.intervalIds.push(intervalId)
191
+
192
+ return scheduledEnqueue
191
193
  }, firstInMs)
192
194
 
193
195
  this.timeoutIds.push(timeoutId)
@@ -242,13 +244,15 @@ export default class BackgroundJobsScheduler {
242
244
  * @returns {Promise<void>} - Resolves after the enqueue attempt finishes.
243
245
  */
244
246
  async runScheduledJob({jobConfiguration, jobKey}) {
247
+ if (this.stopped || this.pendingEnqueuesByJobKey.has(jobKey)) return
248
+
245
249
  const pendingEnqueue = this.enqueueScheduledJob({jobConfiguration, jobKey})
246
- this.pendingEnqueues.add(pendingEnqueue)
250
+ this.pendingEnqueuesByJobKey.set(jobKey, pendingEnqueue)
247
251
 
248
252
  try {
249
253
  await pendingEnqueue
250
254
  } finally {
251
- this.pendingEnqueues.delete(pendingEnqueue)
255
+ this.pendingEnqueuesByJobKey.delete(jobKey)
252
256
  }
253
257
  }
254
258
 
@@ -77,7 +77,8 @@ const SORTABLE_COLUMNS = {
77
77
 
78
78
  /**
79
79
  * Serializes concurrent `_applySchema` runs within THIS process, keyed by database
80
- * identifier. Two stores that share one connection (SingleMultiUse / SQLite)
80
+ * identifier, before callers without an existing connection check one out. Two
81
+ * stores that share one connection (SingleMultiUse / SQLite)
81
82
  * otherwise interleave the multi-step table rebuild and corrupt it (the jobs table
82
83
  * is left as its `*_velocious_rebuild` temp). A DB advisory lock can't fix that: on
83
84
  * a session-scoped / re-entrant driver (MySQL `GET_LOCK`) a second acquire on the
@@ -1059,22 +1060,17 @@ export default class BackgroundJobsStore {
1059
1060
  * @returns {Promise<void>} - Resolves when the schema is present.
1060
1061
  */
1061
1062
  async _ensureSchema(existingDb) {
1062
- if (existingDb) {
1063
- await this._applySchema(existingDb)
1064
-
1065
- return
1066
- }
1067
-
1068
- await this._withDb((db) => this._applySchema(db))
1063
+ await this._applySchema(existingDb)
1069
1064
  }
1070
1065
 
1071
1066
  /**
1072
- * Creates or upgrades the background-jobs tables, columns and concurrency rows on
1073
- * the given connection.
1074
- * @param {import("../database/drivers/base.js").default} db - Database connection.
1067
+ * Serializes creation or upgrade of the background-jobs schema, checking out a
1068
+ * connection only after earlier schema work has completed when one is not supplied.
1069
+ * @param {import("../database/drivers/base.js").default} [existingDb] - Caller-owned
1070
+ * database connection.
1075
1071
  * @returns {Promise<void>} - Resolves when the schema is present.
1076
1072
  */
1077
- async _applySchema(db) {
1073
+ async _applySchema(existingDb) {
1078
1074
  // Serialize concurrent schema applies within this process, keyed by database
1079
1075
  // identifier (see `schemaApplyChains`). The per-step locks inside the steps use
1080
1076
  // DIFFERENT lock names, so two concurrent callers could otherwise each hold a
@@ -1084,7 +1080,16 @@ export default class BackgroundJobsStore {
1084
1080
  // the second caller then re-checks and finds every step already done.
1085
1081
  const identifier = this.getDatabaseIdentifier() ?? "default"
1086
1082
  const previous = schemaApplyChains.get(identifier) ?? Promise.resolve()
1087
- const run = previous.then(() => this._applySchemaSteps(db), () => this._applySchemaSteps(db))
1083
+ const applyWithConnection = async () => {
1084
+ if (existingDb) {
1085
+ await this._applySchemaSteps(existingDb)
1086
+
1087
+ return
1088
+ }
1089
+
1090
+ await this._withDb((db) => this._applySchemaSteps(db))
1091
+ }
1092
+ const run = previous.then(applyWithConnection, applyWithConnection)
1088
1093
 
1089
1094
  // Keep the chain alive regardless of this run's outcome so one failed apply does
1090
1095
  // not wedge later callers; this run still propagates its own result/error.
@@ -312,6 +312,7 @@
312
312
  * @property {string} controller - Controller class name.
313
313
  * @property {string} [action] - Controller action or endpoint label.
314
314
  * @property {"index" | "find" | "create" | "update" | "destroy" | "attach" | "attachmentList" | "download" | "url" | "custom-command"} [commandType] - Frontend-model command type.
315
+ * @property {string} [correlationId] - Server-generated identifier shared by an unexpected client error and framework reports.
315
316
  * @property {boolean} [expectedError] - Whether the error is an expected user-flow failure.
316
317
  * @property {boolean} [frontendModelEndpoint] - Whether the error came from the frontend-model endpoint.
317
318
  * @property {string} [model] - Frontend-model name from the failed request.
@@ -286,6 +286,19 @@ export default class VelociousConfiguration {
286
286
  }
287
287
 
288
288
  this._isInitialized = false
289
+ this._modelsInitialized = false
290
+ /**
291
+ * Invalidates model phases that started before database connections closed.
292
+ * @type {number}
293
+ */
294
+ this._modelInitializationGeneration = 0
295
+ /**
296
+ * In-progress `initializeModels()` promise. Model initialization is an
297
+ * atomic bootstrap phase: concurrent callers share it, and a rejection
298
+ * leaves the phase eligible for a later complete attempt.
299
+ * @type {Promise<void> | undefined}
300
+ */
301
+ this._initializeModelsPromise = undefined
289
302
  /**
290
303
  * In-progress `initialize()` promise, memoized so concurrent callers await
291
304
  * the same bootstrap. Reset to undefined if initialization fails.
@@ -2020,25 +2033,39 @@ export default class VelociousConfiguration {
2020
2033
  * @returns {Promise<void>} - Resolves when complete.
2021
2034
  */
2022
2035
  async initializeModels(args = {type: "server"}) {
2023
- if (!this._modelsInitialized) {
2024
- this._modelsInitialized = true
2036
+ if (this._modelsInitialized) return
2037
+ if (this._initializeModelsPromise) return await this._initializeModelsPromise
2025
2038
 
2039
+ const modelInitializationGeneration = this._modelInitializationGeneration
2040
+ const initializeModelsPromise = (async () => {
2026
2041
  const shouldSkipDummyModelInitialization = process.env.VELOCIOUS_SKIP_DUMMY_MODEL_INITIALIZATION === "1"
2027
2042
  && process.env.VELOCIOUS_BROWSER_TESTS === "true"
2028
2043
  && this.getEnvironment() === "test"
2029
2044
 
2030
- if (shouldSkipDummyModelInitialization) {
2031
- return
2045
+ if (!shouldSkipDummyModelInitialization) {
2046
+ if (this._initializeModels) {
2047
+ await this._initializeModels({configuration: this, type: args.type})
2048
+ }
2049
+
2050
+ await this.getEnvironmentHandler().initializePackageModels(this)
2051
+ await initializeAuditedModelRelationships(this)
2052
+
2053
+ await this.getEnvironmentHandler().initializeFrontendModelWebsocketPublishers(this)
2032
2054
  }
2033
2055
 
2034
- if (this._initializeModels) {
2035
- await this._initializeModels({configuration: this, type: args.type})
2056
+ if (this._modelInitializationGeneration === modelInitializationGeneration) {
2057
+ this._modelsInitialized = true
2036
2058
  }
2059
+ })()
2037
2060
 
2038
- await this.getEnvironmentHandler().initializePackageModels(this)
2039
- await initializeAuditedModelRelationships(this)
2061
+ this._initializeModelsPromise = initializeModelsPromise
2040
2062
 
2041
- await this.getEnvironmentHandler().initializeFrontendModelWebsocketPublishers(this)
2063
+ try {
2064
+ await initializeModelsPromise
2065
+ } finally {
2066
+ if (this._initializeModelsPromise === initializeModelsPromise) {
2067
+ this._initializeModelsPromise = undefined
2068
+ }
2042
2069
  }
2043
2070
  }
2044
2071
 
@@ -2073,6 +2100,12 @@ export default class VelociousConfiguration {
2073
2100
 
2074
2101
  this._initializePromise = (async () => {
2075
2102
  await this.initializeModels({type})
2103
+
2104
+ // Model initialization can be invalidated by a concurrent connection close.
2105
+ // If models are not ready, stop without marking the configuration initialized
2106
+ // so the next caller retries a full bootstrap.
2107
+ if (!this._modelsInitialized) return
2108
+
2076
2109
  await this.getEnvironmentHandler().autoDiscoverResources(this)
2077
2110
  this._mergeDiscoveredAbilityResources()
2078
2111
  this._validateResourceRelationshipsOnModels()
@@ -2104,6 +2137,13 @@ export default class VelociousConfiguration {
2104
2137
  this._initializePromise = undefined
2105
2138
  throw error
2106
2139
  }
2140
+
2141
+ // If the inner IIFE returned without marking the configuration initialized
2142
+ // (e.g. because models were invalidated mid-bootstrap), clear the promise so
2143
+ // a later call retries a full bootstrap.
2144
+ if (!this._isInitialized) {
2145
+ this._initializePromise = undefined
2146
+ }
2107
2147
  }
2108
2148
 
2109
2149
  /**
@@ -3244,8 +3284,10 @@ export default class VelociousConfiguration {
3244
3284
  PoolClass.clearGlobalConnections(this)
3245
3285
  }
3246
3286
 
3247
- // Allow models to be re-initialized after connections are closed.
3287
+ // Allow full re-initialization after connections are closed.
3288
+ this._modelInitializationGeneration += 1
3248
3289
  this._modelsInitialized = false
3290
+ this._isInitialized = false
3249
3291
  }
3250
3292
  })()
3251
3293
 
@@ -20,6 +20,9 @@ export default class VelociousDatabaseDriversMysqlStructureSql {
20
20
  const {driver} = this
21
21
  const isMariaDb = await this._isMariaDb()
22
22
  const rows = await driver.query("SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY table_type, table_name")
23
+ const foreignKeyRows = await driver.query("SELECT table_name, referenced_table_name FROM information_schema.key_column_usage WHERE table_schema = DATABASE() AND referenced_table_schema = DATABASE() AND referenced_table_name IS NOT NULL")
24
+ const baseTableNames = []
25
+ const views = []
23
26
  const statements = []
24
27
 
25
28
  for (const row of rows) {
@@ -31,24 +34,82 @@ export default class VelociousDatabaseDriversMysqlStructureSql {
31
34
  if (!tableName || !tableType) continue
32
35
 
33
36
  if (tableType == "BASE TABLE") {
34
- const createRows = await driver.query(`SHOW CREATE TABLE ${driver.quoteTable(tableName)}`)
35
- const rawCreateStatement = this._mysqlCreateStatement(createRows?.[0])
36
- const createStatement = rawCreateStatement ? this._stripAutoIncrement(rawCreateStatement) : null
37
-
38
- if (createStatement) statements.push(normalizeSqlStatement(createStatement))
37
+ baseTableNames.push(tableName)
39
38
  } else if (tableType == "VIEW" || (isMariaDb && tableType == "SYSTEM VIEW")) {
40
- const createRows = await driver.query(`SHOW CREATE VIEW ${driver.quoteTable(tableName)}`)
41
- const createStatement = this._mysqlCreateStatement(createRows?.[0])
42
-
43
- if (createStatement) statements.push(normalizeSqlStatement(createStatement))
39
+ views.push(tableName)
44
40
  }
45
41
  }
46
42
 
43
+ for (const tableName of this._orderBaseTables({foreignKeyRows, tableNames: baseTableNames})) {
44
+ const createRows = await driver.query(`SHOW CREATE TABLE ${driver.quoteTable(tableName)}`)
45
+ const rawCreateStatement = this._mysqlCreateStatement(createRows?.[0])
46
+ const createStatement = rawCreateStatement ? this._stripAutoIncrement(rawCreateStatement) : null
47
+
48
+ if (createStatement) statements.push(normalizeSqlStatement(createStatement))
49
+ }
50
+
51
+ for (const tableName of views) {
52
+ const createRows = await driver.query(`SHOW CREATE VIEW ${driver.quoteTable(tableName)}`)
53
+ const createStatement = this._mysqlCreateStatement(createRows?.[0])
54
+
55
+ if (createStatement) statements.push(normalizeSqlStatement(createStatement))
56
+ }
57
+
47
58
  if (statements.length == 0) return null
48
59
 
49
60
  return `${statements.join("\n\n")}\n`
50
61
  }
51
62
 
63
+ /**
64
+ * Orders tables so referenced tables are created before their dependents.
65
+ * @param {object} args - Options object.
66
+ * @param {Array<Record<string, ?>>} args.foreignKeyRows - Foreign key metadata rows.
67
+ * @param {string[]} args.tableNames - Base table names in their existing order.
68
+ * @returns {string[]} - Ordered table names.
69
+ */
70
+ _orderBaseTables({foreignKeyRows, tableNames}) {
71
+ const pendingTableNames = new Set(tableNames)
72
+ /** @type {Record<string, Set<string>>} */
73
+ const dependenciesByTableName = {}
74
+ const orderedTableNames = []
75
+
76
+ for (const tableName of tableNames) {
77
+ dependenciesByTableName[tableName] = new Set()
78
+ }
79
+
80
+ for (const row of foreignKeyRows) {
81
+ const tableNameValue = row.table_name || row.TABLE_NAME
82
+ const referencedTableNameValue = row.referenced_table_name || row.REFERENCED_TABLE_NAME
83
+ const tableName = tableNameValue ? String(tableNameValue) : ""
84
+ const referencedTableName = referencedTableNameValue ? String(referencedTableNameValue) : ""
85
+
86
+ if (tableName == referencedTableName || !pendingTableNames.has(tableName) || !pendingTableNames.has(referencedTableName)) continue
87
+
88
+ dependenciesByTableName[tableName].add(referencedTableName)
89
+ }
90
+
91
+ while (pendingTableNames.size > 0) {
92
+ const nextTableName = tableNames.find((tableName) => {
93
+ if (!pendingTableNames.has(tableName)) return false
94
+
95
+ return Array.from(dependenciesByTableName[tableName]).every((dependencyTableName) => !pendingTableNames.has(dependencyTableName))
96
+ })
97
+
98
+ if (!nextTableName) {
99
+ for (const tableName of tableNames) {
100
+ if (pendingTableNames.has(tableName)) orderedTableNames.push(tableName)
101
+ }
102
+
103
+ break
104
+ }
105
+
106
+ orderedTableNames.push(nextTableName)
107
+ pendingTableNames.delete(nextTableName)
108
+ }
109
+
110
+ return orderedTableNames
111
+ }
112
+
52
113
  /**
53
114
  * Runs is maria db.
54
115
  * @returns {Promise<boolean>} - Resolves with Whether maria db.
@@ -161,22 +161,22 @@ function queryForEntry({entry, modelClass, parentIds, sourceModel}) {
161
161
 
162
162
  const foreignKey = relationship.getForeignKey()
163
163
  /**
164
- * Where conditions.
164
+ * Mandatory cohort conditions.
165
165
  * @type {Record<string, ?>} */
166
- const whereConditions = {[foreignKey]: parentIds}
166
+ const mandatoryWhereConditions = {[foreignKey]: parentIds}
167
167
 
168
168
  if (relationship.getPolymorphic && relationship.getPolymorphic()) {
169
169
  const typeColumn = relationship.getPolymorphicTypeColumn()
170
- whereConditions[typeColumn] = modelClass.getModelName()
171
- }
172
-
173
- if (entry.where) {
174
- Object.assign(whereConditions, entry.where)
170
+ mandatoryWhereConditions[typeColumn] = modelClass.getModelName()
175
171
  }
176
172
 
177
173
  const baseQuery = sourceModel.queryForModel(targetModelClass)
178
174
  baseQuery._forceQualifyBaseTable = true
179
- baseQuery.where(whereConditions)
175
+ baseQuery.where(mandatoryWhereConditions)
176
+
177
+ if (entry.where) {
178
+ baseQuery.where(entry.where)
179
+ }
180
180
 
181
181
  const countQuery = relationship.applyScope(baseQuery)
182
182
 
@@ -1,3 +1,4 @@
1
1
  // @ts-check
2
2
 
3
+ /** Backend missing-record error whose diagnostic message stays server-side. */
3
4
  export default class RecordNotFoundError extends Error {}
@@ -6,9 +6,11 @@ import TestFilesFinder from "../../../../testing/test-files-finder.js"
6
6
  import TestRunner from "../../../../testing/test-runner.js"
7
7
  import TestSuiteSplitter from "../../../../testing/test-suite-splitter.js"
8
8
  import {normalizeExamplePatterns, parseFilters} from "../../../../testing/test-filter-parser.js"
9
+ import {prepareSourcePeerPackage} from "../../source-peer-package.js"
9
10
 
10
11
  export default class VelociousCliCommandsTest extends BaseCommand {
11
12
  async execute() {
13
+ await prepareSourcePeerPackage()
12
14
  this.getConfiguration().setEnvironment("test")
13
15
 
14
16
  let directory